@oh-my-pi/omp-stats 18.0.10 → 18.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +101 -101
  4. package/dist/client/styles.css +265 -0
  5. package/dist/types/client/api.d.ts +7 -1
  6. package/dist/types/client/app/routes.d.ts +1 -1
  7. package/dist/types/client/data/useHashRoute.d.ts +2 -0
  8. package/dist/types/client/routes/TracesRoute.d.ts +11 -0
  9. package/dist/types/client/routes/index.d.ts +1 -0
  10. package/dist/types/client/traces/AggregatesPanel.d.ts +9 -0
  11. package/dist/types/client/traces/Minimap.d.ts +14 -0
  12. package/dist/types/client/traces/SpanDrawer.d.ts +14 -0
  13. package/dist/types/client/traces/SummaryStrip.d.ts +9 -0
  14. package/dist/types/client/traces/TimelineCanvas.d.ts +25 -0
  15. package/dist/types/client/traces/TraceView.d.ts +11 -0
  16. package/dist/types/client/traces/TranscriptList.d.ts +24 -0
  17. package/dist/types/client/traces/time-scale.d.ts +40 -0
  18. package/dist/types/client/traces/trace-colors.d.ts +31 -0
  19. package/dist/types/db.d.ts +24 -1
  20. package/dist/types/index.d.ts +1 -1
  21. package/dist/types/parser.d.ts +8 -0
  22. package/dist/types/server.d.ts +3 -2
  23. package/dist/types/shared-types.d.ts +130 -0
  24. package/dist/types/trace.d.ts +33 -0
  25. package/dist/types/types.d.ts +35 -1
  26. package/package.json +8 -8
  27. package/src/client/App.tsx +11 -1
  28. package/src/client/api.ts +23 -0
  29. package/src/client/app/routes.ts +7 -0
  30. package/src/client/data/useHashRoute.ts +28 -10
  31. package/src/client/routes/ProvidersRoute.tsx +1 -0
  32. package/src/client/routes/TracesRoute.tsx +182 -0
  33. package/src/client/routes/index.ts +1 -0
  34. package/src/client/styles.css +316 -0
  35. package/src/client/traces/AggregatesPanel.tsx +88 -0
  36. package/src/client/traces/Minimap.tsx +177 -0
  37. package/src/client/traces/SpanDrawer.tsx +309 -0
  38. package/src/client/traces/SummaryStrip.tsx +37 -0
  39. package/src/client/traces/TimelineCanvas.tsx +768 -0
  40. package/src/client/traces/TraceView.tsx +344 -0
  41. package/src/client/traces/TranscriptList.tsx +166 -0
  42. package/src/client/traces/time-scale.ts +250 -0
  43. package/src/client/traces/trace-colors.ts +77 -0
  44. package/src/db.ts +77 -0
  45. package/src/index.ts +1 -6
  46. package/src/parser.ts +55 -2
  47. package/src/server.ts +69 -17
  48. package/src/shared-types.ts +138 -0
  49. package/src/trace.ts +1002 -0
  50. package/src/types.ts +45 -1
package/src/trace.ts ADDED
@@ -0,0 +1,1002 @@
1
+ /**
2
+ * Session trace assembly for the Traces dashboard section.
3
+ *
4
+ * Builds a span tree (turns, model calls, tool calls, subagents, background
5
+ * jobs) directly from raw session JSONL — the only source with tool start/end
6
+ * times — while stats.db supplies the session-list rollups. Entry shapes are
7
+ * mirrored structurally from the coding-agent journal (`tool_execution_start`,
8
+ * `session_exit`, task results, async-result batches); stats never imports
9
+ * coding-agent.
10
+ */
11
+
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import { getSessionsDir, isEnoent } from "@oh-my-pi/pi-utils";
15
+ import { getSessionRollups, getToolCallCountsBySession } from "./db";
16
+ import { extractFolderFromPath, parseAllSessionEntries } from "./parser";
17
+ import type {
18
+ SessionEntry,
19
+ SessionSummary,
20
+ SessionTrace,
21
+ TraceMarker,
22
+ TraceSpan,
23
+ TraceSummary,
24
+ TraceToolStat,
25
+ TraceTrack,
26
+ } from "./types";
27
+
28
+ /** Client-supplied path escaped the sessions root or is not a transcript. Maps to HTTP 400. */
29
+ export class TracePathError extends Error {}
30
+ /**
31
+ * Trace payload schema/assembly revision, folded into the HTTP ETag. Bump when
32
+ * span assembly changes so browsers don't revalidate stale cached bodies
33
+ * against an unchanged transcript mtime.
34
+ */
35
+ export const TRACE_ETAG_VERSION = 2;
36
+
37
+ const MAX_TRACK_DEPTH = 6;
38
+ const LABEL_MAX = 80;
39
+ const DETAIL_MAX = 160;
40
+ const TITLE_SCAN_BYTES = 4096;
41
+ const LIST_FOLD_LIMIT = 300;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Structural entry views (journal shapes mirrored without importing coding-agent)
45
+
46
+ /**
47
+ * Loose structural view of one journal entry. Session JSONL is
48
+ * outside-controlled data (old versions, crash-truncated turns), so every
49
+ * field is optional and reads are re-checked with `typeof` before use.
50
+ */
51
+ interface EntryView {
52
+ type?: string;
53
+ id?: string;
54
+ parentId?: string | null;
55
+ timestamp?: string;
56
+ customType?: string;
57
+ data?: Record<string, unknown>;
58
+ message?: MessageView;
59
+ // session header
60
+ cwd?: string;
61
+ title?: string;
62
+ // session_init
63
+ agent?: string;
64
+ resolvedModel?: string;
65
+ // compaction
66
+ tokensBefore?: number;
67
+ tokensAfter?: number;
68
+ // model_change / mode_change
69
+ model?: string;
70
+ mode?: string;
71
+ }
72
+
73
+ /** Loose structural view of a persisted message payload. */
74
+ interface MessageView {
75
+ role?: string;
76
+ synthetic?: boolean;
77
+ content?: unknown;
78
+ toolCallId?: string;
79
+ toolName?: string;
80
+ isError?: boolean;
81
+ details?: Record<string, unknown>;
82
+ customType?: string;
83
+ timestamp?: number;
84
+ completedAt?: number;
85
+ duration?: number;
86
+ ttft?: number;
87
+ usage?: { totalTokens?: number; cost?: { total?: number } };
88
+ model?: string;
89
+ provider?: string;
90
+ stopReason?: string;
91
+ errorMessage?: string;
92
+ }
93
+
94
+ /** `tool_execution_start` marker facts joined to tool spans by call id. */
95
+ interface ToolStartFact {
96
+ start: number;
97
+ detail?: string;
98
+ }
99
+
100
+ /** Pending tool call recorded from an assistant turn's `toolCall` blocks. */
101
+ interface PendingToolCall {
102
+ callId: string;
103
+ name: string;
104
+ argsPreview?: string;
105
+ assistantEntryId: string;
106
+ modelEnd: number;
107
+ }
108
+
109
+ /** Task tool result row, used to place subagent spans on the parent track. */
110
+ interface TaskResultFact {
111
+ childId: string;
112
+ agent?: string;
113
+ task?: string;
114
+ durationMs?: number;
115
+ resultEntryId: string;
116
+ toolSpanEnd: number;
117
+ }
118
+
119
+ /** Everything one transcript contributes before cross-track assembly. */
120
+ interface TrackScan {
121
+ track: TraceTrack;
122
+ taskResults: TaskResultFact[];
123
+ requests: number;
124
+ toolCalls: number;
125
+ cwd: string | null;
126
+ title: string | null;
127
+ /** Session-header envelope timestamp (ms), for spanless traces. */
128
+ headerTs: number | undefined;
129
+ firstActivity: number | undefined;
130
+ lastActivity: number | undefined;
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Path containment + transcript IO
135
+
136
+ /** Resolve a client-supplied transcript path, enforcing sessions-root containment. */
137
+ function resolveSessionPath(fileParam: string): string {
138
+ const resolved = path.resolve(fileParam);
139
+ const rel = path.relative(getSessionsDir(), resolved);
140
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
141
+ throw new TracePathError(`Path is outside the sessions directory: ${fileParam}`);
142
+ }
143
+ const base = path.basename(resolved);
144
+ if (!base.endsWith(".jsonl") && !base.endsWith(".jsonl.gz")) {
145
+ throw new TracePathError(`Not a session transcript: ${fileParam}`);
146
+ }
147
+ return resolved;
148
+ }
149
+
150
+ /** Read a transcript, transparently gunzipping gc-compressed sessions. */
151
+ async function readTranscript(resolved: string): Promise<Uint8Array> {
152
+ const bytes = await Bun.file(resolved).bytes();
153
+ return resolved.endsWith(".gz") ? Bun.gunzipSync(bytes) : bytes;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Small pure helpers
158
+
159
+ /** ISO string → ms epoch, undefined when absent/unparseable. */
160
+ function toMs(iso: string | undefined): number | undefined {
161
+ if (typeof iso !== "string") return undefined;
162
+ const ms = Date.parse(iso);
163
+ return Number.isFinite(ms) ? ms : undefined;
164
+ }
165
+
166
+ /** Whitespace-collapsed head of a string, capped at `max` chars. */
167
+ function headText(value: string, max: number): string {
168
+ const collapsed = value.replace(/\s+/g, " ").trim();
169
+ return collapsed.length > max ? `${collapsed.slice(0, max - 1)}…` : collapsed;
170
+ }
171
+
172
+ /** First text-block head of a message content payload. */
173
+ function contentHead(content: unknown, max: number): string | undefined {
174
+ if (typeof content === "string") return content.trim() ? headText(content, max) : undefined;
175
+ if (!Array.isArray(content)) return undefined;
176
+ for (const block of content) {
177
+ if (!block || typeof block !== "object" || !("type" in block) || block.type !== "text") continue;
178
+ if ("text" in block && typeof block.text === "string" && block.text.trim()) return headText(block.text, max);
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ /** Compact token count for marker labels (142_000 → "142k"). */
184
+ function compactTokens(value: unknown): string {
185
+ if (typeof value !== "number" || !Number.isFinite(value)) return "?";
186
+ return value >= 1000 ? `${Math.round(value / 1000)}k` : String(value);
187
+ }
188
+
189
+ /** Merge intervals and return covered length. Mutates (sorts) its input. */
190
+ function unionLength(intervals: Array<[number, number]>): number {
191
+ if (intervals.length === 0) return 0;
192
+ intervals.sort((a, b) => a[0] - b[0]);
193
+ let total = 0;
194
+ let [curStart, curEnd] = intervals[0];
195
+ for (let i = 1; i < intervals.length; i++) {
196
+ const [s, e] = intervals[i];
197
+ if (s > curEnd) {
198
+ total += curEnd - curStart;
199
+ curStart = s;
200
+ curEnd = e;
201
+ } else if (e > curEnd) {
202
+ curEnd = e;
203
+ }
204
+ }
205
+ return total + (curEnd - curStart);
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Single-transcript scan
210
+
211
+ /** Keep only the active branch: the parentId chain walked back from the last entry. */
212
+ function activeChain(views: EntryView[]): EntryView[] {
213
+ const byId = new Map<string, EntryView>();
214
+ let last: EntryView | undefined;
215
+ for (const entry of views) {
216
+ if (typeof entry.id === "string" && entry.id.length > 0) {
217
+ byId.set(entry.id, entry);
218
+ last = entry;
219
+ }
220
+ }
221
+ if (!last) return [];
222
+ const keep = new Set<string>();
223
+ let cursor: EntryView | undefined = last;
224
+ while (cursor) {
225
+ if (typeof cursor.id !== "string" || keep.has(cursor.id)) break;
226
+ keep.add(cursor.id);
227
+ cursor = typeof cursor.parentId === "string" ? byId.get(cursor.parentId) : undefined;
228
+ }
229
+ return views.filter(entry => typeof entry.id === "string" && keep.has(entry.id));
230
+ }
231
+
232
+ /** Argument projection for tool-span details: command/path/intent, else nothing. */
233
+ function toolStartDetail(data: Record<string, unknown>): string | undefined {
234
+ let candidate: unknown;
235
+ const args = data.args;
236
+ if (args && typeof args === "object") {
237
+ if ("command" in args && typeof args.command === "string") candidate = args.command;
238
+ else if ("path" in args && typeof args.path === "string") candidate = args.path;
239
+ }
240
+ candidate ??= data.intent;
241
+ if (typeof candidate === "string" && candidate.trim()) return headText(candidate, DETAIL_MAX);
242
+ return undefined;
243
+ }
244
+
245
+ /** Serialize tool-call arguments into a bounded preview. */
246
+ function argsPreview(args: unknown): string | undefined {
247
+ if (args === undefined || args === null) return undefined;
248
+ try {
249
+ return headText(JSON.stringify(args), DETAIL_MAX);
250
+ } catch {
251
+ return undefined;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Scan one transcript into a track: model/tool/turn/background spans plus
257
+ * markers, and the cross-track facts (task results, request counts).
258
+ */
259
+ function scanTranscript(
260
+ entries: SessionEntry[],
261
+ trackId: string,
262
+ parentTrackId: string | null,
263
+ file: string,
264
+ ): TrackScan {
265
+ // Boundary cast: persisted JSONL entries are re-validated field-by-field below.
266
+ const views = entries as EntryView[];
267
+ const chain = activeChain(views);
268
+ const spans: TraceSpan[] = [];
269
+ const markers: TraceMarker[] = [];
270
+ const pendingTools: PendingToolCall[] = [];
271
+ const toolStarts = new Map<string, ToolStartFact>();
272
+ const toolResults = new Map<string, { end: number; isError: boolean; entryId?: string; toolName: string }>();
273
+ const backgroundOpens: Array<{ jobId: string; start: number; label: string; entryId?: string }> = [];
274
+ const asyncCloses = new Map<string, number>();
275
+ const turns: Array<{ time: number; label: string; entryId?: string }> = [];
276
+ const taskResults: TaskResultFact[] = [];
277
+
278
+ let sessionExit: { recordedAt: number; pendingIds: Set<string> } | undefined;
279
+ let cwd: string | null = null;
280
+ let headerTitle: string | null = null;
281
+ let headerTs: number | undefined;
282
+ let slotTitle: string | null = null;
283
+ let initAgent: string | null = null;
284
+ let initModel: string | null = null;
285
+ let firstAssistantModel: string | null = null;
286
+ let requests = 0;
287
+ let lastChainTs = 0;
288
+
289
+ // Title slot and session header may sit outside the id chain — scan all entries.
290
+ for (const raw of views) {
291
+ if (raw.type === "title" && typeof raw.title === "string") slotTitle = raw.title;
292
+ else if (raw.type === "session") {
293
+ if (typeof raw.cwd === "string") cwd = raw.cwd;
294
+ if (typeof raw.title === "string") headerTitle = raw.title;
295
+ headerTs = toMs(raw.timestamp);
296
+ } else if (raw.type === "session_init") {
297
+ if (typeof raw.agent === "string") initAgent = raw.agent;
298
+ if (typeof raw.resolvedModel === "string") initModel = raw.resolvedModel;
299
+ }
300
+ }
301
+
302
+ for (const entry of chain) {
303
+ const envelopeTs = toMs(entry.timestamp);
304
+ if (envelopeTs !== undefined && envelopeTs > lastChainTs) lastChainTs = envelopeTs;
305
+
306
+ if (entry.type === "custom") {
307
+ const data = entry.data ?? {};
308
+ if (entry.customType === "tool_execution_start") {
309
+ const callId = data.toolCallId;
310
+ if (typeof callId === "string") {
311
+ const start = (typeof data.startedAt === "string" ? toMs(data.startedAt) : undefined) ?? envelopeTs;
312
+ if (start !== undefined) toolStarts.set(callId, { start, detail: toolStartDetail(data) });
313
+ }
314
+ } else if (entry.customType === "session_exit") {
315
+ const recordedAt = (typeof data.recordedAt === "string" ? toMs(data.recordedAt) : undefined) ?? envelopeTs;
316
+ const pendingIds = new Set<string>();
317
+ if (Array.isArray(data.pendingToolCalls)) {
318
+ for (const pending of data.pendingToolCalls) {
319
+ if (!pending || typeof pending !== "object" || !("toolCallId" in pending)) continue;
320
+ if (typeof pending.toolCallId === "string") pendingIds.add(pending.toolCallId);
321
+ }
322
+ }
323
+ if (recordedAt !== undefined) {
324
+ sessionExit = { recordedAt, pendingIds };
325
+ markers.push({
326
+ time: recordedAt,
327
+ kind: "session_exit",
328
+ label: typeof data.kind === "string" ? data.kind : "exit",
329
+ });
330
+ }
331
+ }
332
+ continue;
333
+ }
334
+
335
+ if (entry.type === "compaction" && envelopeTs !== undefined) {
336
+ markers.push({
337
+ time: envelopeTs,
338
+ kind: "compaction",
339
+ label: `compaction ${compactTokens(entry.tokensBefore)}→${compactTokens(entry.tokensAfter)}`,
340
+ });
341
+ continue;
342
+ }
343
+ if (entry.type === "model_change" && envelopeTs !== undefined) {
344
+ markers.push({ time: envelopeTs, kind: "model_change", label: String(entry.model ?? "model change") });
345
+ continue;
346
+ }
347
+ if (entry.type === "mode_change" && envelopeTs !== undefined) {
348
+ markers.push({ time: envelopeTs, kind: "mode_change", label: String(entry.mode ?? "mode change") });
349
+ continue;
350
+ }
351
+ if (entry.type === "reset_boundary" && envelopeTs !== undefined) {
352
+ markers.push({ time: envelopeTs, kind: "reset", label: "reset" });
353
+ continue;
354
+ }
355
+
356
+ if (entry.type !== "message" || !entry.message) continue;
357
+ const msg = entry.message;
358
+
359
+ if (msg.role === "assistant") {
360
+ requests++;
361
+ let start = typeof msg.timestamp === "number" && Number.isFinite(msg.timestamp) ? msg.timestamp : undefined;
362
+ let end =
363
+ typeof msg.completedAt === "number" && Number.isFinite(msg.completedAt)
364
+ ? msg.completedAt
365
+ : start !== undefined && typeof msg.duration === "number"
366
+ ? start + msg.duration
367
+ : undefined;
368
+ if (start === undefined) {
369
+ const fallbackEnd = end ?? envelopeTs;
370
+ if (fallbackEnd === undefined) continue;
371
+ end = fallbackEnd;
372
+ start = end - (typeof msg.duration === "number" ? msg.duration : 0);
373
+ }
374
+ if (end === undefined) end = envelopeTs ?? start;
375
+ end = Math.max(start, end);
376
+ if (typeof msg.model === "string" && firstAssistantModel === null) firstAssistantModel = msg.model;
377
+
378
+ const span: TraceSpan = {
379
+ id: `${trackId}:${entry.id}`,
380
+ kind: "model",
381
+ start,
382
+ end,
383
+ label: headText(msg.model ?? "model", LABEL_MAX),
384
+ };
385
+ const detail = contentHead(msg.content, DETAIL_MAX);
386
+ if (detail) span.detail = detail;
387
+ if (entry.id) span.entryId = entry.id;
388
+ if (typeof msg.model === "string") span.model = msg.model;
389
+ if (typeof msg.usage?.totalTokens === "number") span.tokens = msg.usage.totalTokens;
390
+ if (typeof msg.usage?.cost?.total === "number") span.cost = msg.usage.cost.total;
391
+ if (typeof msg.ttft === "number") span.ttft = msg.ttft;
392
+ if (msg.stopReason === "error" || msg.errorMessage) span.isError = true;
393
+ spans.push(span);
394
+
395
+ if (Array.isArray(msg.content)) {
396
+ for (const block of msg.content) {
397
+ if (!block || typeof block !== "object" || !("type" in block) || block.type !== "toolCall") continue;
398
+ if (!("id" in block) || typeof block.id !== "string") continue;
399
+ if (!("name" in block) || typeof block.name !== "string") continue;
400
+ pendingTools.push({
401
+ callId: block.id,
402
+ name: block.name,
403
+ argsPreview: argsPreview("arguments" in block ? block.arguments : undefined),
404
+ assistantEntryId: entry.id ?? "",
405
+ modelEnd: end,
406
+ });
407
+ }
408
+ }
409
+ continue;
410
+ }
411
+
412
+ if (msg.role === "toolResult") {
413
+ if (typeof msg.toolCallId !== "string") continue;
414
+ const end = typeof msg.timestamp === "number" && Number.isFinite(msg.timestamp) ? msg.timestamp : envelopeTs;
415
+ if (end === undefined) continue;
416
+ toolResults.set(msg.toolCallId, {
417
+ end,
418
+ isError: msg.isError === true,
419
+ entryId: entry.id,
420
+ toolName: msg.toolName ?? "tool",
421
+ });
422
+
423
+ const details = msg.details;
424
+ const asyncInfo = details?.async;
425
+ if (
426
+ asyncInfo &&
427
+ typeof asyncInfo === "object" &&
428
+ "state" in asyncInfo &&
429
+ asyncInfo.state === "running" &&
430
+ "jobId" in asyncInfo &&
431
+ typeof asyncInfo.jobId === "string"
432
+ ) {
433
+ backgroundOpens.push({
434
+ jobId: asyncInfo.jobId,
435
+ start: end,
436
+ label: headText(`${msg.toolName ?? "tool"} job`, LABEL_MAX),
437
+ entryId: entry.id,
438
+ });
439
+ }
440
+ if (msg.toolName === "task" && Array.isArray(details?.results)) {
441
+ for (const result of details.results) {
442
+ if (!result || typeof result !== "object" || !("id" in result) || typeof result.id !== "string")
443
+ continue;
444
+ taskResults.push({
445
+ childId: result.id,
446
+ agent: "agent" in result && typeof result.agent === "string" ? result.agent : undefined,
447
+ task: "task" in result && typeof result.task === "string" ? result.task : undefined,
448
+ durationMs:
449
+ "durationMs" in result && typeof result.durationMs === "number" ? result.durationMs : undefined,
450
+ resultEntryId: entry.id ?? "",
451
+ toolSpanEnd: end,
452
+ });
453
+ }
454
+ }
455
+ continue;
456
+ }
457
+
458
+ if (msg.role === "custom" && msg.customType === "async-result") {
459
+ const closeAt =
460
+ typeof msg.timestamp === "number" && Number.isFinite(msg.timestamp) ? msg.timestamp : envelopeTs;
461
+ const jobs = msg.details?.jobs;
462
+ if (closeAt !== undefined && Array.isArray(jobs)) {
463
+ for (const job of jobs) {
464
+ if (!job || typeof job !== "object" || !("jobId" in job) || typeof job.jobId !== "string") continue;
465
+ if (!asyncCloses.has(job.jobId)) asyncCloses.set(job.jobId, closeAt);
466
+ }
467
+ }
468
+ continue;
469
+ }
470
+
471
+ if (msg.role === "user" && msg.synthetic !== true) {
472
+ const time = typeof msg.timestamp === "number" && Number.isFinite(msg.timestamp) ? msg.timestamp : envelopeTs;
473
+ if (time === undefined) continue;
474
+ turns.push({
475
+ time,
476
+ label: contentHead(msg.content, LABEL_MAX) ?? "user",
477
+ entryId: entry.id,
478
+ });
479
+ }
480
+ }
481
+
482
+ // Tool spans: join call blocks with start markers and results.
483
+ for (const pending of pendingTools) {
484
+ const marker = toolStarts.get(pending.callId);
485
+ const result = toolResults.get(pending.callId);
486
+ const start = marker?.start ?? pending.modelEnd;
487
+ let end: number;
488
+ let unterminated = false;
489
+ if (result) {
490
+ end = result.end;
491
+ } else if (sessionExit?.pendingIds.has(pending.callId)) {
492
+ end = sessionExit.recordedAt;
493
+ unterminated = true;
494
+ } else {
495
+ end = lastChainTs || start;
496
+ unterminated = true;
497
+ }
498
+ const span: TraceSpan = {
499
+ id: `${trackId}:${pending.assistantEntryId}:${pending.callId}`,
500
+ kind: "tool",
501
+ start,
502
+ end: Math.max(start, end),
503
+ label: headText(pending.name, LABEL_MAX),
504
+ toolCallId: pending.callId,
505
+ };
506
+ const detail = marker?.detail ?? pending.argsPreview;
507
+ if (detail) span.detail = detail;
508
+ const entryId = result?.entryId ?? pending.assistantEntryId;
509
+ if (entryId) span.entryId = entryId;
510
+ if (result?.isError) span.isError = true;
511
+ if (unterminated) span.unterminated = true;
512
+ spans.push(span);
513
+ }
514
+
515
+ // Background spans: opened by an async-running tool result, closed by async-result delivery.
516
+ for (const open of backgroundOpens) {
517
+ const close = asyncCloses.get(open.jobId);
518
+ const end = close ?? (lastChainTs || open.start);
519
+ const span: TraceSpan = {
520
+ id: `${trackId}:bg:${open.jobId}`,
521
+ kind: "background",
522
+ start: open.start,
523
+ end: Math.max(open.start, end),
524
+ label: open.label,
525
+ };
526
+ if (open.entryId) span.entryId = open.entryId;
527
+ if (close === undefined) span.unterminated = true;
528
+ spans.push(span);
529
+ }
530
+
531
+ // Turn spans: user message → max end of spans starting before the next turn.
532
+ turns.sort((a, b) => a.time - b.time);
533
+ for (let i = 0; i < turns.length; i++) {
534
+ const turn = turns[i];
535
+ const nextTime = i + 1 < turns.length ? turns[i + 1].time : Number.POSITIVE_INFINITY;
536
+ let end = turn.time;
537
+ for (const span of spans) {
538
+ if (span.start >= turn.time && span.start < nextTime && span.end > end) end = span.end;
539
+ }
540
+ const span: TraceSpan = {
541
+ id: `${trackId}:${turn.entryId ?? `turn${i}`}`,
542
+ kind: "turn",
543
+ start: turn.time,
544
+ end,
545
+ label: turn.label,
546
+ };
547
+ if (turn.entryId) span.entryId = turn.entryId;
548
+ spans.push(span);
549
+ }
550
+
551
+ spans.sort((a, b) => a.start - b.start || a.end - b.end);
552
+ markers.sort((a, b) => a.time - b.time);
553
+
554
+ let firstActivity: number | undefined;
555
+ let lastActivity: number | undefined;
556
+ for (const span of spans) {
557
+ if (firstActivity === undefined || span.start < firstActivity) firstActivity = span.start;
558
+ if (lastActivity === undefined || span.end > lastActivity) lastActivity = span.end;
559
+ }
560
+
561
+ return {
562
+ track: {
563
+ id: trackId,
564
+ parentId: parentTrackId,
565
+ label: "",
566
+ agent: initAgent,
567
+ model: initModel ?? firstAssistantModel,
568
+ file,
569
+ spans,
570
+ markers,
571
+ },
572
+ taskResults,
573
+ requests,
574
+ toolCalls: pendingTools.length,
575
+ cwd,
576
+ title: slotTitle ?? headerTitle,
577
+ headerTs,
578
+ firstActivity,
579
+ lastActivity,
580
+ };
581
+ }
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // Recursive track tree
585
+
586
+ /** Strip the transcript extension (`.jsonl` / `.jsonl.gz`). */
587
+ function transcriptStem(file: string): string {
588
+ return file.endsWith(".jsonl.gz") ? file.slice(0, -".jsonl.gz".length) : file.slice(0, -".jsonl".length);
589
+ }
590
+
591
+ /**
592
+ * Build the track for one transcript and recurse into its artifacts directory.
593
+ * Appends tracks to `out` in DFS order and places subagent spans on parents.
594
+ */
595
+ async function buildTrackTree(
596
+ file: string,
597
+ trackId: string,
598
+ parentTrackId: string | null,
599
+ depth: number,
600
+ visited: Set<string>,
601
+ out: TraceTrack[],
602
+ scans: TrackScan[],
603
+ ): Promise<TrackScan | null> {
604
+ if (visited.has(file)) return null;
605
+ visited.add(file);
606
+
607
+ let bytes: Uint8Array;
608
+ try {
609
+ bytes = await readTranscript(file);
610
+ } catch (err) {
611
+ if (isEnoent(err)) return null;
612
+ throw err;
613
+ }
614
+
615
+ const scan = scanTranscript(parseAllSessionEntries(bytes), trackId, parentTrackId, file);
616
+ out.push(scan.track);
617
+ scans.push(scan);
618
+
619
+ const childrenById = new Map<string, TrackScan>();
620
+ if (depth < MAX_TRACK_DEPTH) {
621
+ const artifactsDir = transcriptStem(file);
622
+ let names: string[] = [];
623
+ try {
624
+ names = await fs.readdir(artifactsDir);
625
+ } catch (err) {
626
+ if (!isEnoent(err)) throw err;
627
+ }
628
+ names.sort();
629
+ for (const name of names) {
630
+ // `.jsonl.*.bak` backups fail both suffix checks and are skipped.
631
+ if (!name.endsWith(".jsonl") && !name.endsWith(".jsonl.gz")) continue;
632
+ const childFile = path.join(artifactsDir, name);
633
+ const stem = path.basename(transcriptStem(childFile));
634
+ const childTrackId = trackId === "main" ? stem : `${trackId}/${stem}`;
635
+ const childScan = await buildTrackTree(childFile, childTrackId, trackId, depth + 1, visited, out, scans);
636
+ if (!childScan) continue;
637
+ const isAdvisor = name === "__advisor.jsonl" || (name.startsWith("__advisor.") && name.endsWith(".jsonl"));
638
+ childScan.track.label = isAdvisor
639
+ ? "Advisor"
640
+ : headText(`${childScan.track.agent ?? "agent"} ${stem}`, LABEL_MAX);
641
+ childrenById.set(stem, childScan);
642
+ }
643
+ }
644
+
645
+ // Subagent spans on this track, one per task SingleResult.
646
+ const linkedChildren = new Set<string>();
647
+ for (const result of scan.taskResults) {
648
+ const child = childrenById.get(result.childId);
649
+ if (child) linkedChildren.add(result.childId);
650
+ let start: number;
651
+ let end: number;
652
+ if (child && child.firstActivity !== undefined && child.lastActivity !== undefined) {
653
+ start = child.firstActivity;
654
+ end = child.lastActivity;
655
+ } else if (result.durationMs !== undefined) {
656
+ start = result.toolSpanEnd - result.durationMs;
657
+ end = result.toolSpanEnd;
658
+ } else {
659
+ continue;
660
+ }
661
+ const span: TraceSpan = {
662
+ id: `${trackId}:${result.resultEntryId}:${result.childId}`,
663
+ kind: "subagent",
664
+ start,
665
+ end: Math.max(start, end),
666
+ label: headText(result.agent ?? result.childId, LABEL_MAX),
667
+ };
668
+ if (result.task) span.detail = headText(result.task, DETAIL_MAX);
669
+ if (result.resultEntryId) span.entryId = result.resultEntryId;
670
+ if (child) {
671
+ span.childTrackId = child.track.id;
672
+ if (child.track.model) span.model = child.track.model;
673
+ }
674
+ scan.track.spans.push(span);
675
+ }
676
+ // Children with no structured task result — async task jobs (results are
677
+ // delivered as formatted text), crashed parents, advisors — still get a
678
+ // parent span from their own activity bounds so the Agents lane shows them.
679
+ for (const [stem, child] of childrenById) {
680
+ if (linkedChildren.has(stem)) continue;
681
+ if (child.firstActivity === undefined || child.lastActivity === undefined) continue;
682
+ const span: TraceSpan = {
683
+ id: `${trackId}:sub:${stem}`,
684
+ kind: "subagent",
685
+ start: child.firstActivity,
686
+ end: Math.max(child.firstActivity, child.lastActivity),
687
+ label: headText(child.track.agent ?? child.track.label, LABEL_MAX),
688
+ childTrackId: child.track.id,
689
+ };
690
+ if (child.track.model) span.model = child.track.model;
691
+ scan.track.spans.push(span);
692
+ }
693
+ scan.track.spans.sort((a, b) => a.start - b.start || a.end - b.end);
694
+
695
+ return scan;
696
+ }
697
+
698
+ // ---------------------------------------------------------------------------
699
+ // Public surface
700
+
701
+ /**
702
+ * Assemble the full span tree for one root session transcript.
703
+ * Throws {@link TracePathError} for paths outside the sessions root; ENOENT
704
+ * passes through for the caller's 404 mapping.
705
+ */
706
+ export async function buildSessionTrace(fileParam: string): Promise<SessionTrace> {
707
+ const resolved = resolveSessionPath(fileParam);
708
+ const rootStat = await fs.stat(resolved);
709
+
710
+ const tracks: TraceTrack[] = [];
711
+ const scans: TrackScan[] = [];
712
+ const rootScan = await buildTrackTree(resolved, "main", null, 0, new Set(), tracks, scans);
713
+ if (!rootScan) {
714
+ throw Object.assign(new Error(`ENOENT: session not found: ${resolved}`), { code: "ENOENT" });
715
+ }
716
+ rootScan.track.label = "Main";
717
+
718
+ let startedAt: number | undefined;
719
+ let endedAt: number | undefined;
720
+ const intervals: Array<[number, number]> = [];
721
+ let toolCalls = 0;
722
+ let requests = 0;
723
+ let totalTokens = 0;
724
+ let costTotal = 0;
725
+ const toolStats = new Map<string, TraceToolStat>();
726
+
727
+ for (const scan of scans) {
728
+ requests += scan.requests;
729
+ toolCalls += scan.toolCalls;
730
+ for (const span of scan.track.spans) {
731
+ intervals.push([span.start, span.end]);
732
+ if (startedAt === undefined || span.start < startedAt) startedAt = span.start;
733
+ if (endedAt === undefined || span.end > endedAt) endedAt = span.end;
734
+ if (span.kind === "model") {
735
+ if (span.tokens) totalTokens += span.tokens;
736
+ if (span.cost) costTotal += span.cost;
737
+ } else if (span.kind === "tool") {
738
+ const duration = span.end - span.start;
739
+ const agg = toolStats.get(span.label);
740
+ if (agg) {
741
+ agg.calls++;
742
+ if (span.isError) agg.errors++;
743
+ agg.totalMs += duration;
744
+ if (duration > agg.maxMs) agg.maxMs = duration;
745
+ } else {
746
+ toolStats.set(span.label, {
747
+ tool: span.label,
748
+ calls: 1,
749
+ errors: span.isError ? 1 : 0,
750
+ totalMs: duration,
751
+ maxMs: duration,
752
+ });
753
+ }
754
+ }
755
+ }
756
+ }
757
+
758
+ let modelMs = 0;
759
+ let toolMs = 0;
760
+ let turns = 0;
761
+ for (const span of rootScan.track.spans) {
762
+ if (span.kind === "model") modelMs += span.end - span.start;
763
+ else if (span.kind === "tool") toolMs += span.end - span.start;
764
+ else if (span.kind === "turn") turns++;
765
+ }
766
+
767
+ const start = startedAt ?? rootScan.headerTs ?? rootStat.mtimeMs;
768
+ const end = endedAt ?? start;
769
+ const wallMs = end - start;
770
+
771
+ const summary: TraceSummary = {
772
+ wallMs,
773
+ modelMs,
774
+ toolMs,
775
+ idleMs: Math.max(0, wallMs - unionLength(intervals)),
776
+ turns,
777
+ requests,
778
+ toolCalls,
779
+ subagents: tracks.length - 1,
780
+ totalTokens,
781
+ costTotal,
782
+ toolStats: [...toolStats.values()].sort((a, b) => b.totalMs - a.totalMs),
783
+ };
784
+
785
+ return {
786
+ file: resolved,
787
+ title: rootScan.title,
788
+ cwd: rootScan.cwd,
789
+ startedAt: start,
790
+ endedAt: end,
791
+ mtimeMs: rootStat.mtimeMs,
792
+ tracks,
793
+ summary,
794
+ };
795
+ }
796
+
797
+ /** Fetch one full journal entry by id from a transcript, for the span drawer. */
798
+ export async function getTraceEntry(fileParam: string, entryId: string): Promise<SessionEntry | null> {
799
+ const resolved = resolveSessionPath(fileParam);
800
+ let bytes: Uint8Array;
801
+ try {
802
+ bytes = await readTranscript(resolved);
803
+ } catch (err) {
804
+ if (isEnoent(err)) return null;
805
+ throw err;
806
+ }
807
+ for (const entry of parseAllSessionEntries(bytes)) {
808
+ if ("id" in entry && entry.id === entryId) return entry;
809
+ }
810
+ return null;
811
+ }
812
+
813
+ // ---------------------------------------------------------------------------
814
+ // Session list
815
+
816
+ const titleCache = new Map<string, { mtimeMs: number; title: string | null }>();
817
+
818
+ /** Read the session title from the fixed title slot or the header line. */
819
+ async function readSessionTitle(file: string): Promise<string | null> {
820
+ let mtimeMs: number;
821
+ try {
822
+ mtimeMs = (await fs.stat(file)).mtimeMs;
823
+ } catch {
824
+ return null;
825
+ }
826
+ const cached = titleCache.get(file);
827
+ if (cached && cached.mtimeMs === mtimeMs) return cached.title;
828
+
829
+ let title: string | null = null;
830
+ try {
831
+ const head = await Bun.file(file).slice(0, TITLE_SCAN_BYTES).text();
832
+ for (const line of head.split("\n")) {
833
+ if (!line.trim()) continue;
834
+ let parsed: EntryView;
835
+ try {
836
+ // Boundary cast: fields re-checked with typeof below.
837
+ parsed = JSON.parse(line) as EntryView;
838
+ } catch {
839
+ continue;
840
+ }
841
+ if (parsed.type === "title" && typeof parsed.title === "string") {
842
+ title = parsed.title;
843
+ break;
844
+ }
845
+ if (parsed.type === "session") {
846
+ if (typeof parsed.title === "string") title = parsed.title;
847
+ break;
848
+ }
849
+ }
850
+ } catch {
851
+ // Unreadable head (gc'd, gz, permission) → keep the row, title null.
852
+ }
853
+ titleCache.set(file, { mtimeMs, title });
854
+ return title;
855
+ }
856
+
857
+ interface SummaryFold extends SessionSummary {
858
+ modelSet: Set<string>;
859
+ }
860
+ /** Parse the ms timestamp from a session basename (`2026-09-01T10-24-49-741Z_<uuid>.jsonl`). */
861
+ function basenameTimestamp(base: string): number | undefined {
862
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z_/.exec(base);
863
+ if (!match) return undefined;
864
+ const ms = Date.parse(`${match[1]}T${match[2]}:${match[3]}:${match[4]}.${match[5]}Z`);
865
+ return Number.isFinite(ms) ? ms : undefined;
866
+ }
867
+
868
+ /**
869
+ * Root session transcripts found on disk, newest-first by mtime. Lets the
870
+ * Traces list include sessions the sync pass hasn't indexed yet (most
871
+ * importantly the currently running one).
872
+ */
873
+ async function scanDiskRoots(limit: number): Promise<Array<{ file: string; mtimeMs: number; startedAt: number }>> {
874
+ const sessionsDir = getSessionsDir();
875
+ let projects: string[] = [];
876
+ try {
877
+ projects = await fs.readdir(sessionsDir);
878
+ } catch (err) {
879
+ if (isEnoent(err)) return [];
880
+ throw err;
881
+ }
882
+ const roots: Array<{ file: string; mtimeMs: number; startedAt: number }> = [];
883
+ await Promise.all(
884
+ projects.map(async project => {
885
+ let names: string[] = [];
886
+ try {
887
+ names = await fs.readdir(path.join(sessionsDir, project));
888
+ } catch {
889
+ return; // Not a directory or unreadable — skip.
890
+ }
891
+ await Promise.all(
892
+ names.map(async name => {
893
+ if (!name.endsWith(".jsonl") && !name.endsWith(".jsonl.gz")) return;
894
+ const file = path.join(sessionsDir, project, name);
895
+ try {
896
+ const stat = await fs.stat(file);
897
+ roots.push({ file, mtimeMs: stat.mtimeMs, startedAt: basenameTimestamp(name) ?? stat.mtimeMs });
898
+ } catch {
899
+ // Raced with gc — skip.
900
+ }
901
+ }),
902
+ );
903
+ }),
904
+ );
905
+ roots.sort((a, b) => b.mtimeMs - a.mtimeMs);
906
+ return roots.slice(0, limit);
907
+ }
908
+
909
+ /**
910
+ * List root sessions for the Traces section, folding every synced child
911
+ * transcript (subagents, advisors) into its root row.
912
+ */
913
+ export async function listSessionSummaries(limit = 100, q?: string): Promise<SessionSummary[]> {
914
+ const sessionsDir = getSessionsDir();
915
+ const toolCounts = getToolCallCountsBySession();
916
+ const byRoot = new Map<string, SummaryFold>();
917
+
918
+ for (const row of getSessionRollups()) {
919
+ const rel = path.relative(sessionsDir, row.sessionFile);
920
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
921
+ const segments = rel.split(path.sep);
922
+ if (segments.length < 2) continue;
923
+ const isChild = segments.length > 2;
924
+ const rootFile = isChild ? path.join(sessionsDir, segments[0], `${segments[1]}.jsonl`) : row.sessionFile;
925
+
926
+ let fold = byRoot.get(rootFile);
927
+ if (!fold) {
928
+ fold = {
929
+ file: rootFile,
930
+ folder: extractFolderFromPath(rootFile),
931
+ title: null,
932
+ startedAt: row.startedAt,
933
+ endedAt: row.endedAt,
934
+ requests: 0,
935
+ toolCalls: 0,
936
+ subagents: 0,
937
+ totalTokens: 0,
938
+ costTotal: 0,
939
+ models: [],
940
+ modelSet: new Set(),
941
+ };
942
+ byRoot.set(rootFile, fold);
943
+ }
944
+ fold.requests += row.requests;
945
+ fold.toolCalls += toolCounts.get(row.sessionFile) ?? 0;
946
+ if (isChild) fold.subagents++;
947
+ if (row.startedAt < fold.startedAt) fold.startedAt = row.startedAt;
948
+ if (row.endedAt > fold.endedAt) fold.endedAt = row.endedAt;
949
+ fold.totalTokens += row.totalTokens ?? 0;
950
+ fold.costTotal += row.costTotal ?? 0;
951
+ if (row.models) {
952
+ for (const model of row.models.split(",")) {
953
+ if (model) fold.modelSet.add(model);
954
+ }
955
+ }
956
+ }
957
+
958
+ // Fold in on-disk roots the sync pass hasn't indexed yet, so the currently
959
+ // running session shows up without a manual Sync.
960
+ for (const root of await scanDiskRoots(LIST_FOLD_LIMIT)) {
961
+ const existing = byRoot.get(root.file);
962
+ if (existing) {
963
+ // Live sessions keep writing after the last sync; sort by real recency.
964
+ if (root.mtimeMs > existing.endedAt) existing.endedAt = root.mtimeMs;
965
+ continue;
966
+ }
967
+ byRoot.set(root.file, {
968
+ file: root.file,
969
+ folder: extractFolderFromPath(root.file),
970
+ title: null,
971
+ startedAt: root.startedAt,
972
+ endedAt: root.mtimeMs,
973
+ requests: 0,
974
+ toolCalls: 0,
975
+ subagents: 0,
976
+ totalTokens: 0,
977
+ costTotal: 0,
978
+ models: [],
979
+ modelSet: new Set(),
980
+ });
981
+ }
982
+
983
+ const folds = [...byRoot.values()].sort((a, b) => b.endedAt - a.endedAt).slice(0, LIST_FOLD_LIMIT);
984
+ await Promise.all(
985
+ folds.map(async fold => {
986
+ fold.title = await readSessionTitle(fold.file);
987
+ fold.models = [...fold.modelSet].sort();
988
+ }),
989
+ );
990
+
991
+ let rows: SessionSummary[] = folds.map(({ modelSet: _modelSet, ...summary }) => summary);
992
+ if (q?.trim()) {
993
+ const needle = q.trim().toLowerCase();
994
+ rows = rows.filter(
995
+ row =>
996
+ (row.title ?? "").toLowerCase().includes(needle) ||
997
+ row.folder.toLowerCase().includes(needle) ||
998
+ path.basename(row.file).toLowerCase().includes(needle),
999
+ );
1000
+ }
1001
+ return rows.slice(0, limit);
1002
+ }