@neiltron/session-visualizer 0.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.
@@ -0,0 +1,1371 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { readFileSync as readFileSync2, realpathSync } from "node:fs";
5
+ import { dirname, join as join2, resolve as resolve3 } from "node:path";
6
+ import process2 from "node:process";
7
+ import { spawn } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ // src/lib/cli-options.ts
11
+ var DEFAULT_PORT = 4174;
12
+ function parseCliOptions(argv) {
13
+ const options = {
14
+ sourceRoot: void 0,
15
+ open: false,
16
+ port: DEFAULT_PORT,
17
+ help: false,
18
+ version: false
19
+ };
20
+ for (let index = 0; index < argv.length; index += 1) {
21
+ const argument = argv[index] ?? "";
22
+ if (argument === "--help" || argument === "-h") {
23
+ options.help = true;
24
+ continue;
25
+ }
26
+ if (argument === "--version" || argument === "-v") {
27
+ options.version = true;
28
+ continue;
29
+ }
30
+ if (argument === "--open") {
31
+ options.open = true;
32
+ continue;
33
+ }
34
+ if (argument === "--port") {
35
+ const nextValue = argv[index + 1];
36
+ if (!nextValue) {
37
+ return invalid(options, "Missing value for --port.");
38
+ }
39
+ const port = Number(nextValue);
40
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
41
+ return invalid(options, `Invalid port: ${nextValue}`);
42
+ }
43
+ options.port = port;
44
+ index += 1;
45
+ continue;
46
+ }
47
+ if (argument.startsWith("--port=")) {
48
+ const value = argument.slice("--port=".length);
49
+ const port = Number(value);
50
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
51
+ return invalid(options, `Invalid port: ${value}`);
52
+ }
53
+ options.port = port;
54
+ continue;
55
+ }
56
+ if (argument.startsWith("-")) {
57
+ return invalid(options, `Unknown option: ${argument}`);
58
+ }
59
+ if (options.sourceRoot !== void 0) {
60
+ return invalid(options, `Unexpected extra argument: ${argument}`);
61
+ }
62
+ options.sourceRoot = argument;
63
+ }
64
+ return { ok: true, options, error: null };
65
+ }
66
+ function renderHelp(commandName, packageName) {
67
+ return [
68
+ `${commandName} \u2014 local filmstrip inspector for Pi session JSONL files`,
69
+ "",
70
+ `Usage:`,
71
+ ` npx ${packageName} [source-root] [--open] [--port <number>]`,
72
+ ` ${commandName} [source-root] [--open] [--port <number>]`,
73
+ "",
74
+ `Arguments:`,
75
+ ` source-root Optional directory containing Pi session JSONL files`,
76
+ "",
77
+ `Options:`,
78
+ ` --open Open the default browser after the server is ready`,
79
+ ` --port <number> Port to bind (default: ${DEFAULT_PORT}; use 0 for an ephemeral port)`,
80
+ ` -h, --help Show this help`,
81
+ ` -v, --version Show package version`,
82
+ "",
83
+ `Examples:`,
84
+ ` npx ${packageName} /path/to/logs`,
85
+ ` npx ${packageName} --open`,
86
+ ` ${commandName} /path/to/logs --port 4300`
87
+ ].join("\n");
88
+ }
89
+ function invalid(options, error) {
90
+ return { ok: false, options, error };
91
+ }
92
+
93
+ // src/lib/node-server.ts
94
+ import { createServer } from "node:http";
95
+ import { Readable } from "node:stream";
96
+
97
+ // src/server/app-handler.ts
98
+ import { existsSync, readFileSync } from "node:fs";
99
+ import { extname, isAbsolute as isAbsolute2, join, relative as relative2, resolve as resolve2 } from "node:path";
100
+
101
+ // src/lib/discovery.ts
102
+ import { constants } from "node:fs";
103
+ import { access, readFile, readdir } from "node:fs/promises";
104
+ import { basename, isAbsolute, posix as posix2, relative, resolve, sep } from "node:path";
105
+
106
+ // src/lib/normalize.ts
107
+ import { posix } from "node:path";
108
+
109
+ // src/lib/format.ts
110
+ function parseTimestamp(value) {
111
+ if (typeof value === "number" && Number.isFinite(value)) {
112
+ return value;
113
+ }
114
+ if (typeof value === "string" && value.trim()) {
115
+ const parsed = Date.parse(value);
116
+ return Number.isNaN(parsed) ? null : parsed;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ // src/lib/normalize.ts
122
+ function buildSessionDetail({ relativePath, parsed }) {
123
+ const session = buildSessionSummary(relativePath, parsed.rawEntries);
124
+ const events = normalizeEvents(parsed.rawEntries);
125
+ const toolSpans = buildToolSpans(events);
126
+ applyToolPairMetadata(events, toolSpans);
127
+ const stats = buildStats(parsed.rawEntries, parsed.warnings.length);
128
+ return {
129
+ session,
130
+ stats,
131
+ warnings: parsed.warnings,
132
+ rawEntries: parsed.rawEntries,
133
+ events,
134
+ toolSpans
135
+ };
136
+ }
137
+ function buildSessionIndexRow(detail) {
138
+ return {
139
+ ...detail.session,
140
+ ...detail.stats
141
+ };
142
+ }
143
+ function buildSessionSummary(relativePath, rawEntries) {
144
+ const pathMetadata = deriveSessionPathMetadata(relativePath);
145
+ const timestamps = rawEntries.map((record) => getEntryTimeMs(record.entry)).filter((value) => value !== null).sort((left, right) => left - right);
146
+ const startedAt = rawEntries.find((record) => record.entry.type === "session")?.entry.timestamp;
147
+ const latestModelChange = [...rawEntries].reverse().find((record) => record.entry.type === "model_change")?.entry;
148
+ const latestMessageWithModel = [...rawEntries].reverse().find(
149
+ (record) => {
150
+ return isMessageEntry(record.entry) && (typeof record.entry.provider === "string" || typeof record.entry.model === "string");
151
+ }
152
+ )?.entry;
153
+ const startedAtIso = typeof startedAt === "string" ? startedAt : timestamps.length > 0 ? new Date(timestamps[0]).toISOString() : null;
154
+ return {
155
+ ...pathMetadata,
156
+ startedAt: startedAtIso,
157
+ durationMs: timestamps.length >= 2 ? timestamps[timestamps.length - 1] - timestamps[0] : null,
158
+ provider: latestModelChange && typeof latestModelChange.provider === "string" ? latestModelChange.provider : typeof latestMessageWithModel?.provider === "string" ? latestMessageWithModel.provider : null,
159
+ modelId: latestModelChange && typeof latestModelChange.modelId === "string" ? latestModelChange.modelId : typeof latestMessageWithModel?.model === "string" ? latestMessageWithModel.model : null
160
+ };
161
+ }
162
+ function deriveSessionPathMetadata(relativePath) {
163
+ const normalizedPath = posix.normalize(relativePath.replace(/\\/g, "/")).replace(/^\.\//, "");
164
+ const parts = normalizedPath.split("/").filter(Boolean);
165
+ const fileName = parts.at(-1) ?? normalizedPath;
166
+ const parentParts = parts.slice(0, -1);
167
+ if (parts.length >= 4 && parts[0] === "tasks" && parts[2] === "pi-sessions") {
168
+ const taskId = parts[1] ?? null;
169
+ const nestedParts = parentParts.slice(3);
170
+ return {
171
+ relativePath: normalizedPath,
172
+ fileName,
173
+ groupLabel: taskId ? nestedParts.length > 0 ? `${taskId} / ${nestedParts.join("/")}` : taskId : parentParts.join("/") || null,
174
+ layoutHint: "task",
175
+ taskId,
176
+ chatId: null
177
+ };
178
+ }
179
+ if (parts.length >= 4 && parts[0] === "chats" && parts[2] === "pi-sessions") {
180
+ const chatId = parts[1] ?? null;
181
+ const nestedParts = parentParts.slice(3);
182
+ return {
183
+ relativePath: normalizedPath,
184
+ fileName,
185
+ groupLabel: chatId ? nestedParts.length > 0 ? `${chatId} / ${nestedParts.join("/")}` : chatId : parentParts.join("/") || null,
186
+ layoutHint: "chat",
187
+ taskId: null,
188
+ chatId
189
+ };
190
+ }
191
+ return {
192
+ relativePath: normalizedPath,
193
+ fileName,
194
+ groupLabel: parentParts.length > 0 ? parentParts.join("/") : null,
195
+ layoutHint: "generic",
196
+ taskId: null,
197
+ chatId: null
198
+ };
199
+ }
200
+ function buildStats(rawEntries, warningCount = 0) {
201
+ let toolCount = 0;
202
+ let errorCount = 0;
203
+ let customCount = 0;
204
+ for (const record of rawEntries) {
205
+ const entry = record.entry;
206
+ if (entry.type === "custom") {
207
+ customCount += 1;
208
+ continue;
209
+ }
210
+ if (!isMessageEntry(entry)) {
211
+ continue;
212
+ }
213
+ const message = entry.message;
214
+ if (!message) {
215
+ continue;
216
+ }
217
+ if (message.role === "assistant") {
218
+ toolCount += (message.content ?? []).filter((item) => item.type === "toolCall").length;
219
+ }
220
+ if (message.role === "toolResult" && message.isError) {
221
+ errorCount += 1;
222
+ }
223
+ }
224
+ return {
225
+ eventCount: rawEntries.length,
226
+ toolCount,
227
+ errorCount,
228
+ customCount,
229
+ warningCount
230
+ };
231
+ }
232
+ function normalizeEvents(rawEntries) {
233
+ const events = [];
234
+ let sequence = 0;
235
+ rawEntries.forEach((record, rawEntryIndex) => {
236
+ const entry = record.entry;
237
+ const entryTimeMs = getEntryTimeMs(entry);
238
+ const entryTimestamp = typeof entry.timestamp === "string" ? entry.timestamp : entryTimeMs !== null ? new Date(entryTimeMs).toISOString() : null;
239
+ if (entry.type === "session") {
240
+ events.push(createEvent({
241
+ id: entry.id ?? `session-${rawEntryIndex}`,
242
+ sequence: sequence += 1,
243
+ rawEntryIndex,
244
+ rawEntryId: entry.id ?? null,
245
+ contentIndex: null,
246
+ lane: "metadata",
247
+ type: entry.type,
248
+ role: null,
249
+ label: "session start",
250
+ preview: typeof entry.cwd === "string" ? entry.cwd : null,
251
+ timestamp: entryTimestamp,
252
+ timeMs: entryTimeMs,
253
+ provider: null,
254
+ modelId: null,
255
+ toolName: null,
256
+ toolCallId: null,
257
+ pairedToolEventId: null,
258
+ pairedToolSpanId: null,
259
+ pairStatus: null,
260
+ isError: false,
261
+ hasImage: false,
262
+ attachmentCount: 0,
263
+ badge: "session"
264
+ }));
265
+ return;
266
+ }
267
+ if (entry.type === "model_change") {
268
+ events.push(createEvent({
269
+ id: entry.id ?? `model-${rawEntryIndex}`,
270
+ sequence: sequence += 1,
271
+ rawEntryIndex,
272
+ rawEntryId: entry.id ?? null,
273
+ contentIndex: null,
274
+ lane: "metadata",
275
+ type: entry.type,
276
+ role: null,
277
+ label: "model change",
278
+ preview: compactParts([
279
+ typeof entry.provider === "string" ? entry.provider : void 0,
280
+ typeof entry.modelId === "string" ? entry.modelId : void 0
281
+ ]).join(" / ") || null,
282
+ timestamp: entryTimestamp,
283
+ timeMs: entryTimeMs,
284
+ provider: typeof entry.provider === "string" ? entry.provider : null,
285
+ modelId: typeof entry.modelId === "string" ? entry.modelId : null,
286
+ toolName: null,
287
+ toolCallId: null,
288
+ pairedToolEventId: null,
289
+ pairedToolSpanId: null,
290
+ pairStatus: null,
291
+ isError: false,
292
+ hasImage: false,
293
+ attachmentCount: 0,
294
+ badge: typeof entry.modelId === "string" ? entry.modelId : null
295
+ }));
296
+ return;
297
+ }
298
+ if (entry.type === "thinking_level_change") {
299
+ events.push(createEvent({
300
+ id: entry.id ?? `thinking-level-${rawEntryIndex}`,
301
+ sequence: sequence += 1,
302
+ rawEntryIndex,
303
+ rawEntryId: entry.id ?? null,
304
+ contentIndex: null,
305
+ lane: "metadata",
306
+ type: entry.type,
307
+ role: null,
308
+ label: "thinking level",
309
+ preview: typeof entry.thinkingLevel === "string" ? entry.thinkingLevel : null,
310
+ timestamp: entryTimestamp,
311
+ timeMs: entryTimeMs,
312
+ provider: null,
313
+ modelId: null,
314
+ toolName: null,
315
+ toolCallId: null,
316
+ pairedToolEventId: null,
317
+ pairedToolSpanId: null,
318
+ pairStatus: null,
319
+ isError: false,
320
+ hasImage: false,
321
+ attachmentCount: 0,
322
+ badge: typeof entry.thinkingLevel === "string" ? entry.thinkingLevel : null
323
+ }));
324
+ return;
325
+ }
326
+ if (entry.type === "custom") {
327
+ events.push(createEvent({
328
+ id: entry.id ?? `custom-${rawEntryIndex}`,
329
+ sequence: sequence += 1,
330
+ rawEntryIndex,
331
+ rawEntryId: entry.id ?? null,
332
+ contentIndex: null,
333
+ lane: "custom",
334
+ type: entry.type,
335
+ role: null,
336
+ label: `custom: ${entry.customType ?? "event"}`,
337
+ preview: previewFromUnknown(entry.data),
338
+ timestamp: entryTimestamp,
339
+ timeMs: entryTimeMs,
340
+ provider: null,
341
+ modelId: null,
342
+ toolName: null,
343
+ toolCallId: null,
344
+ pairedToolEventId: null,
345
+ pairedToolSpanId: null,
346
+ pairStatus: null,
347
+ isError: false,
348
+ hasImage: false,
349
+ attachmentCount: 0,
350
+ badge: typeof entry.customType === "string" ? entry.customType : "custom"
351
+ }));
352
+ return;
353
+ }
354
+ if (!isMessageEntry(entry)) {
355
+ events.push(createEvent({
356
+ id: entry.id ?? `entry-${rawEntryIndex}`,
357
+ sequence: sequence += 1,
358
+ rawEntryIndex,
359
+ rawEntryId: entry.id ?? null,
360
+ contentIndex: null,
361
+ lane: "metadata",
362
+ type: entry.type,
363
+ role: null,
364
+ label: entry.type,
365
+ preview: null,
366
+ timestamp: entryTimestamp,
367
+ timeMs: entryTimeMs,
368
+ provider: null,
369
+ modelId: null,
370
+ toolName: null,
371
+ toolCallId: null,
372
+ pairedToolEventId: null,
373
+ pairedToolSpanId: null,
374
+ pairStatus: null,
375
+ isError: false,
376
+ hasImage: false,
377
+ attachmentCount: 0,
378
+ badge: null
379
+ }));
380
+ return;
381
+ }
382
+ const message = entry.message;
383
+ if (!message) {
384
+ return;
385
+ }
386
+ const role = typeof message.role === "string" ? message.role : null;
387
+ const messageProvider = typeof entry.provider === "string" ? entry.provider : null;
388
+ const messageModel = typeof entry.model === "string" ? entry.model : null;
389
+ if (role === "toolResult") {
390
+ const content = message.content ?? [];
391
+ const preview = getFirstText(content) ?? (content.some((item) => item.type === "image") ? "Image attachment" : null);
392
+ const hasImage = content.some((item) => item.type === "image");
393
+ const attachmentCount = content.filter((item) => item.type === "image").length;
394
+ events.push(createEvent({
395
+ id: entry.id ?? `tool-result-${rawEntryIndex}`,
396
+ sequence: sequence += 1,
397
+ rawEntryIndex,
398
+ rawEntryId: entry.id ?? null,
399
+ contentIndex: null,
400
+ lane: "tools",
401
+ type: "toolResult",
402
+ role,
403
+ label: `toolResult: ${message.toolName ?? "tool"}`,
404
+ preview,
405
+ timestamp: entryTimestamp,
406
+ timeMs: entryTimeMs,
407
+ provider: messageProvider,
408
+ modelId: messageModel,
409
+ toolName: typeof message.toolName === "string" ? message.toolName : null,
410
+ toolCallId: typeof message.toolCallId === "string" ? message.toolCallId : null,
411
+ pairedToolEventId: null,
412
+ pairedToolSpanId: null,
413
+ pairStatus: null,
414
+ isError: Boolean(message.isError),
415
+ hasImage,
416
+ attachmentCount,
417
+ badge: message.isError ? "error" : attachmentCount > 0 ? `${attachmentCount} image` : null
418
+ }));
419
+ return;
420
+ }
421
+ const contents = message.content ?? [];
422
+ contents.forEach((content, contentIndex) => {
423
+ const normalized = normalizeMessageContent({
424
+ entry,
425
+ content,
426
+ rawEntryIndex,
427
+ contentIndex,
428
+ role,
429
+ timestamp: entryTimestamp,
430
+ timeMs: entryTimeMs,
431
+ sequence: sequence += 1
432
+ });
433
+ if (normalized) {
434
+ events.push(normalized);
435
+ }
436
+ });
437
+ });
438
+ return events.sort((left, right) => {
439
+ const leftTime = left.timeMs ?? Number.MAX_SAFE_INTEGER;
440
+ const rightTime = right.timeMs ?? Number.MAX_SAFE_INTEGER;
441
+ if (leftTime !== rightTime) {
442
+ return leftTime - rightTime;
443
+ }
444
+ if (left.rawEntryIndex !== right.rawEntryIndex) {
445
+ return left.rawEntryIndex - right.rawEntryIndex;
446
+ }
447
+ return left.sequence - right.sequence;
448
+ });
449
+ }
450
+ function normalizeMessageContent(input) {
451
+ const { entry, content, rawEntryIndex, contentIndex, role, timestamp, timeMs, sequence } = input;
452
+ const provider = typeof entry.provider === "string" ? entry.provider : null;
453
+ const modelId = typeof entry.model === "string" ? entry.model : null;
454
+ if (role === "user") {
455
+ return createEvent({
456
+ id: `${entry.id ?? `message-${rawEntryIndex}`}:user:${contentIndex}`,
457
+ sequence,
458
+ rawEntryIndex,
459
+ rawEntryId: entry.id ?? null,
460
+ contentIndex,
461
+ lane: "user",
462
+ type: content.type,
463
+ role,
464
+ label: content.type === "text" ? "user prompt" : `user ${content.type}`,
465
+ preview: previewFromContent(content),
466
+ timestamp,
467
+ timeMs,
468
+ provider,
469
+ modelId,
470
+ toolName: null,
471
+ toolCallId: null,
472
+ pairedToolEventId: null,
473
+ pairedToolSpanId: null,
474
+ pairStatus: null,
475
+ isError: false,
476
+ hasImage: content.type === "image",
477
+ attachmentCount: content.type === "image" ? 1 : 0,
478
+ badge: content.type
479
+ });
480
+ }
481
+ if (role === "assistant") {
482
+ if (content.type === "toolCall") {
483
+ return createEvent({
484
+ id: `${entry.id ?? `message-${rawEntryIndex}`}:toolCall:${contentIndex}`,
485
+ sequence,
486
+ rawEntryIndex,
487
+ rawEntryId: entry.id ?? null,
488
+ contentIndex,
489
+ lane: "tools",
490
+ type: content.type,
491
+ role,
492
+ label: `toolCall: ${content.name ?? "tool"}`,
493
+ preview: previewFromContent(content),
494
+ timestamp,
495
+ timeMs,
496
+ provider,
497
+ modelId,
498
+ toolName: typeof content.name === "string" ? content.name : null,
499
+ toolCallId: typeof content.id === "string" ? content.id : null,
500
+ pairedToolEventId: null,
501
+ pairedToolSpanId: null,
502
+ pairStatus: null,
503
+ isError: false,
504
+ hasImage: false,
505
+ attachmentCount: 0,
506
+ badge: typeof content.name === "string" ? content.name : "tool"
507
+ });
508
+ }
509
+ return createEvent({
510
+ id: `${entry.id ?? `message-${rawEntryIndex}`}:assistant:${contentIndex}`,
511
+ sequence,
512
+ rawEntryIndex,
513
+ rawEntryId: entry.id ?? null,
514
+ contentIndex,
515
+ lane: "assistant",
516
+ type: content.type,
517
+ role,
518
+ label: content.type === "thinking" ? "assistant thinking" : content.type === "text" ? "assistant reply" : `assistant ${content.type}`,
519
+ preview: previewFromContent(content),
520
+ timestamp,
521
+ timeMs,
522
+ provider,
523
+ modelId,
524
+ toolName: null,
525
+ toolCallId: null,
526
+ pairedToolEventId: null,
527
+ pairedToolSpanId: null,
528
+ pairStatus: null,
529
+ isError: false,
530
+ hasImage: content.type === "image",
531
+ attachmentCount: content.type === "image" ? 1 : 0,
532
+ badge: content.type
533
+ });
534
+ }
535
+ return createEvent({
536
+ id: `${entry.id ?? `message-${rawEntryIndex}`}:${role ?? "message"}:${contentIndex}`,
537
+ sequence,
538
+ rawEntryIndex,
539
+ rawEntryId: entry.id ?? null,
540
+ contentIndex,
541
+ lane: "metadata",
542
+ type: content.type,
543
+ role,
544
+ label: role ? `${role} ${content.type}` : content.type,
545
+ preview: previewFromContent(content),
546
+ timestamp,
547
+ timeMs,
548
+ provider,
549
+ modelId,
550
+ toolName: null,
551
+ toolCallId: null,
552
+ pairedToolEventId: null,
553
+ pairedToolSpanId: null,
554
+ pairStatus: null,
555
+ isError: false,
556
+ hasImage: content.type === "image",
557
+ attachmentCount: content.type === "image" ? 1 : 0,
558
+ badge: content.type
559
+ });
560
+ }
561
+ function createEvent(event) {
562
+ return event;
563
+ }
564
+ function buildToolSpans(events) {
565
+ const toolCalls = /* @__PURE__ */ new Map();
566
+ const toolResults = /* @__PURE__ */ new Map();
567
+ events.forEach((event) => {
568
+ if (!event.toolCallId) {
569
+ return;
570
+ }
571
+ if (event.type === "toolCall") {
572
+ toolCalls.set(event.toolCallId, event);
573
+ return;
574
+ }
575
+ if (event.type === "toolResult") {
576
+ toolResults.set(event.toolCallId, event);
577
+ }
578
+ });
579
+ const spans = [];
580
+ for (const [toolCallId, callEvent] of toolCalls) {
581
+ const resultEvent = toolResults.get(toolCallId) ?? null;
582
+ spans.push({
583
+ id: `tool-span:${toolCallId}`,
584
+ toolCallId,
585
+ toolName: callEvent.toolName,
586
+ toolCallEventId: callEvent.id,
587
+ toolResultEventId: resultEvent?.id ?? null,
588
+ startedAt: callEvent.timestamp,
589
+ endedAt: resultEvent?.timestamp ?? null,
590
+ startTimeMs: callEvent.timeMs,
591
+ endTimeMs: resultEvent?.timeMs ?? null,
592
+ durationMs: callEvent.timeMs !== null && resultEvent && resultEvent.timeMs !== null && resultEvent.timeMs >= callEvent.timeMs ? resultEvent.timeMs - callEvent.timeMs : null,
593
+ isError: Boolean(resultEvent?.isError),
594
+ hasImage: Boolean(resultEvent?.hasImage),
595
+ pairStatus: resultEvent ? "paired" : "orphan_call",
596
+ label: `tool: ${callEvent.toolName ?? "tool"}`
597
+ });
598
+ }
599
+ return spans;
600
+ }
601
+ function applyToolPairMetadata(events, toolSpans) {
602
+ const eventsById = new Map(events.map((event) => [event.id, event]));
603
+ const toolCalls = new Set(toolSpans.map((span) => span.toolCallEventId));
604
+ const toolResults = new Set(toolSpans.flatMap((span) => span.toolResultEventId ? [span.toolResultEventId] : []));
605
+ toolSpans.forEach((span) => {
606
+ const callEvent = eventsById.get(span.toolCallEventId);
607
+ if (callEvent) {
608
+ callEvent.pairStatus = span.pairStatus;
609
+ callEvent.pairedToolEventId = span.toolResultEventId;
610
+ callEvent.pairedToolSpanId = span.id;
611
+ }
612
+ if (!span.toolResultEventId) {
613
+ return;
614
+ }
615
+ const resultEvent = eventsById.get(span.toolResultEventId);
616
+ if (resultEvent) {
617
+ resultEvent.pairStatus = "paired";
618
+ resultEvent.pairedToolEventId = span.toolCallEventId;
619
+ resultEvent.pairedToolSpanId = span.id;
620
+ }
621
+ });
622
+ events.forEach((event) => {
623
+ if (event.type === "toolCall" && !toolCalls.has(event.id)) {
624
+ event.pairStatus = "orphan_call";
625
+ }
626
+ if (event.type === "toolResult" && !toolResults.has(event.id)) {
627
+ event.pairStatus = "orphan_result";
628
+ }
629
+ });
630
+ }
631
+ function getEntryTimeMs(entry) {
632
+ const topLevel = parseTimestamp(entry.timestamp);
633
+ if (topLevel !== null) {
634
+ return topLevel;
635
+ }
636
+ if (isMessageEntry(entry)) {
637
+ return parseTimestamp(entry.message?.timestamp);
638
+ }
639
+ return null;
640
+ }
641
+ function getFirstText(content) {
642
+ for (const item of content) {
643
+ const preview = previewFromContent(item);
644
+ if (preview) {
645
+ return preview;
646
+ }
647
+ }
648
+ return null;
649
+ }
650
+ function previewFromContent(content) {
651
+ if (content.type === "text" && typeof content.text === "string") {
652
+ return compactText(content.text);
653
+ }
654
+ if (content.type === "thinking" && typeof content.thinking === "string") {
655
+ return compactText(content.thinking);
656
+ }
657
+ if (content.type === "toolCall") {
658
+ if (typeof content.partialJson === "string" && content.partialJson.trim()) {
659
+ return compactText(content.partialJson);
660
+ }
661
+ return previewFromUnknown(content.arguments);
662
+ }
663
+ if (content.type === "image") {
664
+ return typeof content.alt === "string" ? content.alt : typeof content.url === "string" ? content.url : "Image attachment";
665
+ }
666
+ return previewFromUnknown(content);
667
+ }
668
+ function previewFromUnknown(value) {
669
+ if (typeof value === "string") {
670
+ return compactText(value);
671
+ }
672
+ if (value && typeof value === "object") {
673
+ try {
674
+ return compactText(JSON.stringify(value));
675
+ } catch {
676
+ return null;
677
+ }
678
+ }
679
+ return null;
680
+ }
681
+ function compactText(value) {
682
+ return value.replace(/\s+/g, " ").trim();
683
+ }
684
+ function compactParts(values) {
685
+ return values.filter((value) => Boolean(value && value.trim()));
686
+ }
687
+ function isMessageEntry(entry) {
688
+ return entry.type === "message" && typeof entry === "object" && entry !== null;
689
+ }
690
+
691
+ // src/lib/parser.ts
692
+ function parseJsonlContent(content) {
693
+ const lines = content.split(/\r?\n/);
694
+ const rawEntries = [];
695
+ const warnings = [];
696
+ let lastNonEmptyIndex = -1;
697
+ for (let index = 0; index < lines.length; index += 1) {
698
+ if (lines[index]?.trim()) {
699
+ lastNonEmptyIndex = index;
700
+ }
701
+ }
702
+ for (let index = 0; index < lines.length; index += 1) {
703
+ const source = lines[index] ?? "";
704
+ const trimmed = source.trim();
705
+ if (!trimmed) {
706
+ continue;
707
+ }
708
+ try {
709
+ rawEntries.push({
710
+ lineNumber: index + 1,
711
+ source,
712
+ entry: JSON.parse(trimmed)
713
+ });
714
+ } catch (error) {
715
+ const isLast = index === lastNonEmptyIndex;
716
+ warnings.push({
717
+ type: isLast ? "trailing_partial_line" : "malformed_line",
718
+ lineNumber: index + 1,
719
+ source,
720
+ message: error instanceof Error ? error.message : "Invalid JSON"
721
+ });
722
+ }
723
+ }
724
+ return { rawEntries, warnings };
725
+ }
726
+
727
+ // src/lib/discovery.ts
728
+ var SKIP_DIRECTORY_NAMES = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", ".next", ".turbo"]);
729
+ var RECOGNIZED_ENTRY_TYPES = /* @__PURE__ */ new Set(["session", "message", "model_change", "thinking_level_change", "custom"]);
730
+ function resolveSourceRoot(explicitRoot) {
731
+ if (explicitRoot === null) {
732
+ return null;
733
+ }
734
+ if (explicitRoot && explicitRoot.trim()) {
735
+ return resolve(explicitRoot);
736
+ }
737
+ return null;
738
+ }
739
+ function createSessionSource(rootPath, overrides = {}) {
740
+ return {
741
+ rootPath,
742
+ rootLabel: rootPath ? basename(rootPath) || rootPath : null,
743
+ status: rootPath ? "ready" : "unset",
744
+ error: null,
745
+ ...overrides
746
+ };
747
+ }
748
+ async function validateSourceRoot(inputPath) {
749
+ if (!inputPath.trim()) {
750
+ throw new Error("Enter a directory path to open.");
751
+ }
752
+ const rootPath = resolve(inputPath);
753
+ try {
754
+ await access(rootPath, constants.R_OK);
755
+ await readdir(rootPath, { withFileTypes: true });
756
+ } catch (error) {
757
+ throw new Error(formatDirectoryError(rootPath, error));
758
+ }
759
+ return rootPath;
760
+ }
761
+ async function scanSessionIndex(rootPath) {
762
+ const sessionFiles = await discoverSessionFiles(rootPath);
763
+ const rows = await Promise.all(
764
+ sessionFiles.map(async ({ relativePath, absolutePath }) => {
765
+ try {
766
+ const parsed = await readParsedSessionFile(absolutePath);
767
+ if (!isRecognizedSessionFile(parsed)) {
768
+ return null;
769
+ }
770
+ return buildSessionIndexRow(buildSessionDetail({ relativePath, parsed }));
771
+ } catch {
772
+ return null;
773
+ }
774
+ })
775
+ );
776
+ return rows.filter((row) => row !== null).sort((left, right) => {
777
+ const leftTime = left.startedAt ? Date.parse(left.startedAt) : 0;
778
+ const rightTime = right.startedAt ? Date.parse(right.startedAt) : 0;
779
+ if (leftTime !== rightTime) {
780
+ return rightTime - leftTime;
781
+ }
782
+ return left.relativePath.localeCompare(right.relativePath);
783
+ });
784
+ }
785
+ async function getSessionDetail(rootPath, relativePath) {
786
+ const sessionPath = resolveSessionPath(rootPath, relativePath);
787
+ if (!sessionPath) {
788
+ return null;
789
+ }
790
+ try {
791
+ await access(sessionPath, constants.R_OK);
792
+ } catch {
793
+ return null;
794
+ }
795
+ try {
796
+ const parsed = await readParsedSessionFile(sessionPath);
797
+ if (!isRecognizedSessionFile(parsed)) {
798
+ return null;
799
+ }
800
+ const normalizedRelativePath = toPortableRelativePath(rootPath, sessionPath);
801
+ return buildSessionDetail({ relativePath: normalizedRelativePath, parsed });
802
+ } catch {
803
+ return null;
804
+ }
805
+ }
806
+ async function discoverSessionFiles(rootPath) {
807
+ const discovered = [];
808
+ await walkDirectory(rootPath, rootPath, discovered, true);
809
+ return discovered.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
810
+ }
811
+ function resolveSessionPath(rootPath, relativePath) {
812
+ const normalizedRoot = resolve(rootPath);
813
+ const normalizedInput = relativePath.replace(/\\/g, "/");
814
+ if (!normalizedInput.trim() || normalizedInput.includes("\0") || posix2.isAbsolute(normalizedInput)) {
815
+ return null;
816
+ }
817
+ const normalizedRelativePath = posix2.normalize(normalizedInput).replace(/^\.\//, "");
818
+ if (!normalizedRelativePath || normalizedRelativePath === "." || normalizedRelativePath.startsWith("../") || normalizedRelativePath === ".." || !normalizedRelativePath.endsWith(".jsonl")) {
819
+ return null;
820
+ }
821
+ const resolvedPath = resolve(normalizedRoot, ...normalizedRelativePath.split("/"));
822
+ return isPathWithinRoot(normalizedRoot, resolvedPath) ? resolvedPath : null;
823
+ }
824
+ async function walkDirectory(rootPath, currentPath, discovered, isRoot = false) {
825
+ let entries;
826
+ try {
827
+ entries = await readdir(currentPath, { withFileTypes: true });
828
+ } catch (error) {
829
+ if (isRoot) {
830
+ throw new Error(formatDirectoryError(currentPath, error));
831
+ }
832
+ return;
833
+ }
834
+ const sortedEntries = [...entries].sort((left, right) => left.name.localeCompare(right.name));
835
+ for (const entry of sortedEntries) {
836
+ if (entry.isSymbolicLink()) {
837
+ continue;
838
+ }
839
+ const absolutePath = resolve(currentPath, entry.name);
840
+ if (entry.isDirectory()) {
841
+ if (SKIP_DIRECTORY_NAMES.has(entry.name)) {
842
+ continue;
843
+ }
844
+ await walkDirectory(rootPath, absolutePath, discovered);
845
+ continue;
846
+ }
847
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
848
+ continue;
849
+ }
850
+ discovered.push({
851
+ relativePath: toPortableRelativePath(rootPath, absolutePath),
852
+ absolutePath
853
+ });
854
+ }
855
+ }
856
+ function toPortableRelativePath(rootPath, absolutePath) {
857
+ return relative(rootPath, absolutePath).split(sep).join("/");
858
+ }
859
+ function isPathWithinRoot(rootPath, candidatePath) {
860
+ const relativeCandidate = relative(rootPath, candidatePath);
861
+ return relativeCandidate === "" || !relativeCandidate.startsWith("..") && !isAbsolute(relativeCandidate);
862
+ }
863
+ function isRecognizedSessionFile(parsed) {
864
+ return parsed.rawEntries.some((record) => RECOGNIZED_ENTRY_TYPES.has(record.entry.type));
865
+ }
866
+ function formatDirectoryError(rootPath, error) {
867
+ if (error && typeof error === "object" && "code" in error) {
868
+ const code = String(error.code);
869
+ if (code === "ENOENT") {
870
+ return `Directory does not exist: ${rootPath}`;
871
+ }
872
+ if (code === "ENOTDIR") {
873
+ return `Path is not a directory: ${rootPath}`;
874
+ }
875
+ if (code === "EACCES" || code === "EPERM") {
876
+ return `Directory is not readable: ${rootPath}`;
877
+ }
878
+ }
879
+ return `Could not read directory: ${rootPath}`;
880
+ }
881
+ async function readParsedSessionFile(path) {
882
+ const content = await readFile(path, "utf8");
883
+ return parseJsonlContent(content);
884
+ }
885
+
886
+ // src/server/allowed-hosts.ts
887
+ function defaultAllowedHosts(host, port) {
888
+ const allowedHosts = [formatHostAndPort(host, port)];
889
+ if (host === "127.0.0.1") {
890
+ allowedHosts.push(formatHostAndPort("localhost", port));
891
+ }
892
+ return allowedHosts;
893
+ }
894
+ function formatHostAndPort(host, port) {
895
+ if (host.includes(":")) {
896
+ return `[${host}]:${port}`;
897
+ }
898
+ return `${host}:${port}`;
899
+ }
900
+
901
+ // src/server/app-handler.ts
902
+ var CONTENT_TYPES = /* @__PURE__ */ new Map([
903
+ [".css", "text/css; charset=utf-8"],
904
+ [".gif", "image/gif"],
905
+ [".html", "text/html; charset=utf-8"],
906
+ [".ico", "image/x-icon"],
907
+ [".jpeg", "image/jpeg"],
908
+ [".jpg", "image/jpeg"],
909
+ [".js", "text/javascript; charset=utf-8"],
910
+ [".json", "application/json; charset=utf-8"],
911
+ [".mjs", "text/javascript; charset=utf-8"],
912
+ [".png", "image/png"],
913
+ [".svg", "image/svg+xml"],
914
+ [".txt", "text/plain; charset=utf-8"],
915
+ [".webp", "image/webp"]
916
+ ]);
917
+ function createAppFetchHandler(options = {}) {
918
+ const configuredRoot = options.dataDir === void 0 ? process.env.DATA_DIR : options.dataDir;
919
+ const initialRoot = resolveSourceRoot(configuredRoot);
920
+ const distDir = resolve2(options.distDir ?? join(process.cwd(), "dist"));
921
+ const host = options.host ?? process.env.HOST ?? "127.0.0.1";
922
+ const port = options.port ?? Number(process.env.PORT ?? 4174);
923
+ const allowedHosts = new Set(options.allowedHosts ?? defaultAllowedHosts(host, port));
924
+ let source = createSessionSource(initialRoot, initialRoot ? { status: "scanning" } : void 0);
925
+ let sessions = [];
926
+ let scanPromise = null;
927
+ let scanGeneration = 0;
928
+ if (initialRoot) {
929
+ void startScan(initialRoot);
930
+ }
931
+ return async function handleRequest(request) {
932
+ const url = new URL(request.url);
933
+ const hostError = validateRequestHost(request, url, allowedHosts);
934
+ if (hostError) {
935
+ return json(403, hostError);
936
+ }
937
+ const originError = validateRequestOrigin(request, allowedHosts);
938
+ if (originError) {
939
+ return json(403, originError);
940
+ }
941
+ if (request.method === "GET" && url.pathname === "/health") {
942
+ return json(200, {
943
+ ok: true,
944
+ host,
945
+ port,
946
+ source
947
+ });
948
+ }
949
+ if (request.method === "GET" && url.pathname === "/api/source") {
950
+ return json(200, { ok: true, source });
951
+ }
952
+ if (request.method === "POST" && url.pathname === "/api/source/open") {
953
+ const body = await readJsonBody(request);
954
+ if (!body || typeof body.path !== "string") {
955
+ return json(400, { ok: false, error: "invalid_request", message: "Expected a JSON body with a string path.", source });
956
+ }
957
+ const result = await openSource(body.path);
958
+ return json(result.ok ? 200 : 400, result);
959
+ }
960
+ if (request.method === "POST" && url.pathname === "/api/source/rescan") {
961
+ const result = await rescanSource();
962
+ return json(result.ok ? 200 : 400, result);
963
+ }
964
+ if (request.method === "GET" && url.pathname === "/api/sessions") {
965
+ await waitForPendingScan();
966
+ return json(200, { ok: true, sessions });
967
+ }
968
+ if (request.method === "GET" && url.pathname === "/api/sessions/detail") {
969
+ if (!source.rootPath) {
970
+ return json(400, { ok: false, error: "source_not_selected" });
971
+ }
972
+ const relativePath = url.searchParams.get("path") ?? "";
973
+ if (!resolveSessionPath(source.rootPath, relativePath)) {
974
+ return json(400, { ok: false, error: "invalid_path" });
975
+ }
976
+ const detail = await getSessionDetail(source.rootPath, relativePath);
977
+ if (!detail) {
978
+ return json(404, { ok: false, error: "not_found" });
979
+ }
980
+ return json(200, { ok: true, detail });
981
+ }
982
+ if (request.method !== "GET") {
983
+ return json(405, { ok: false, error: "method_not_allowed" });
984
+ }
985
+ const staticResponse = serveStatic(url.pathname, distDir);
986
+ if (staticResponse) {
987
+ return staticResponse;
988
+ }
989
+ return json(404, { ok: false, error: "not_found" });
990
+ };
991
+ async function openSource(inputPath) {
992
+ const previousSource = source;
993
+ const previousSessions = sessions;
994
+ let nextRootPath;
995
+ try {
996
+ nextRootPath = await validateSourceRoot(inputPath);
997
+ } catch (error) {
998
+ const message = getErrorMessage(error);
999
+ source = previousSource.rootPath ? { ...previousSource, error: message } : createSessionSource(null, { status: "error", error: message });
1000
+ sessions = previousSessions;
1001
+ return {
1002
+ ok: false,
1003
+ error: "invalid_source_path",
1004
+ message,
1005
+ source
1006
+ };
1007
+ }
1008
+ sessions = [];
1009
+ await startScan(nextRootPath);
1010
+ if (source.status === "error") {
1011
+ return {
1012
+ ok: false,
1013
+ error: "scan_failed",
1014
+ message: source.error ?? "Failed to scan source root.",
1015
+ source
1016
+ };
1017
+ }
1018
+ return { ok: true, source };
1019
+ }
1020
+ async function rescanSource() {
1021
+ if (!source.rootPath) {
1022
+ source = createSessionSource(null, { status: "error", error: "Choose a source root before rescanning." });
1023
+ return {
1024
+ ok: false,
1025
+ error: "source_not_selected",
1026
+ message: source.error,
1027
+ source
1028
+ };
1029
+ }
1030
+ await startScan(source.rootPath);
1031
+ if (source.status === "error") {
1032
+ return {
1033
+ ok: false,
1034
+ error: "scan_failed",
1035
+ message: source.error ?? "Failed to scan source root.",
1036
+ source
1037
+ };
1038
+ }
1039
+ return { ok: true, source };
1040
+ }
1041
+ async function startScan(rootPath) {
1042
+ const generation = ++scanGeneration;
1043
+ source = createSessionSource(rootPath, { status: "scanning", error: null });
1044
+ const pendingScan = scanSessionIndex(rootPath).then((nextSessions) => {
1045
+ if (generation !== scanGeneration || source.rootPath !== rootPath) {
1046
+ return;
1047
+ }
1048
+ sessions = nextSessions;
1049
+ source = createSessionSource(rootPath, { status: "ready", error: null });
1050
+ }).catch((error) => {
1051
+ if (generation !== scanGeneration || source.rootPath !== rootPath) {
1052
+ return;
1053
+ }
1054
+ sessions = [];
1055
+ source = createSessionSource(rootPath, {
1056
+ status: "error",
1057
+ error: getErrorMessage(error)
1058
+ });
1059
+ }).finally(() => {
1060
+ if (scanPromise === pendingScan) {
1061
+ scanPromise = null;
1062
+ }
1063
+ });
1064
+ scanPromise = pendingScan;
1065
+ await pendingScan;
1066
+ }
1067
+ async function waitForPendingScan() {
1068
+ if (scanPromise) {
1069
+ await scanPromise;
1070
+ }
1071
+ }
1072
+ }
1073
+ function validateRequestHost(request, url, allowedHosts) {
1074
+ const requestHost = request.headers.get("host") ?? url.host;
1075
+ if (!requestHost || allowedHosts.has(requestHost)) {
1076
+ return null;
1077
+ }
1078
+ return {
1079
+ ok: false,
1080
+ error: "invalid_host",
1081
+ message: `Requests must use one of: ${[...allowedHosts].join(", ")}.`
1082
+ };
1083
+ }
1084
+ function validateRequestOrigin(request, allowedHosts) {
1085
+ const origin = request.headers.get("origin");
1086
+ if (!origin) {
1087
+ return null;
1088
+ }
1089
+ try {
1090
+ const originUrl = new URL(origin);
1091
+ if (allowedHosts.has(originUrl.host)) {
1092
+ return null;
1093
+ }
1094
+ } catch {
1095
+ }
1096
+ return {
1097
+ ok: false,
1098
+ error: "invalid_origin",
1099
+ message: "Cross-origin requests are not allowed."
1100
+ };
1101
+ }
1102
+ function serveStatic(pathname, distDir) {
1103
+ if (!existsSync(distDir)) {
1104
+ return null;
1105
+ }
1106
+ const normalizedPath = pathname === "/" ? "/index.html" : pathname;
1107
+ const safePath = resolve2(distDir, `.${normalizedPath}`);
1108
+ if (isPathWithinDirectory(distDir, safePath) && existsSync(safePath)) {
1109
+ return fileResponse(safePath);
1110
+ }
1111
+ const indexPath = join(distDir, "index.html");
1112
+ if (existsSync(indexPath)) {
1113
+ return fileResponse(indexPath, "text/html; charset=utf-8");
1114
+ }
1115
+ return null;
1116
+ }
1117
+ function fileResponse(path, contentType = CONTENT_TYPES.get(extname(path)) ?? "application/octet-stream") {
1118
+ return new Response(readFileSync(path), {
1119
+ headers: { "content-type": contentType }
1120
+ });
1121
+ }
1122
+ async function readJsonBody(request) {
1123
+ try {
1124
+ return await request.json();
1125
+ } catch {
1126
+ return null;
1127
+ }
1128
+ }
1129
+ function getErrorMessage(error) {
1130
+ return error instanceof Error ? error.message : "Unexpected error";
1131
+ }
1132
+ function isPathWithinDirectory(rootPath, candidatePath) {
1133
+ const relativeCandidate = relative2(rootPath, candidatePath);
1134
+ return relativeCandidate === "" || !relativeCandidate.startsWith("..") && !isAbsolute2(relativeCandidate);
1135
+ }
1136
+ function json(status, body) {
1137
+ return new Response(JSON.stringify(body), {
1138
+ status,
1139
+ headers: { "content-type": "application/json; charset=utf-8" }
1140
+ });
1141
+ }
1142
+
1143
+ // src/lib/node-server.ts
1144
+ async function startNodeServer(options = {}) {
1145
+ const requestedHost = options.host ?? "127.0.0.1";
1146
+ const requestedPort = options.port ?? 4174;
1147
+ let handleRequest = createAppFetchHandler({
1148
+ ...options,
1149
+ host: requestedHost,
1150
+ port: requestedPort
1151
+ });
1152
+ const server = createServer(async (request, response) => {
1153
+ try {
1154
+ const appRequest = toWebRequest(request, requestedHost, getListeningPort(server, requestedPort));
1155
+ const appResponse = await handleRequest(appRequest);
1156
+ await writeWebResponse(appResponse, response);
1157
+ } catch (error) {
1158
+ response.statusCode = 500;
1159
+ response.setHeader("content-type", "application/json; charset=utf-8");
1160
+ response.end(JSON.stringify({
1161
+ ok: false,
1162
+ error: "internal_server_error",
1163
+ message: error instanceof Error ? error.message : "Unexpected error"
1164
+ }));
1165
+ }
1166
+ });
1167
+ await new Promise((resolvePromise, rejectPromise) => {
1168
+ server.once("error", rejectPromise);
1169
+ server.listen(requestedPort, requestedHost, () => {
1170
+ server.off("error", rejectPromise);
1171
+ resolvePromise();
1172
+ });
1173
+ });
1174
+ const address = server.address();
1175
+ if (!address || typeof address === "string") {
1176
+ throw new Error("Could not determine the Node server address.");
1177
+ }
1178
+ handleRequest = createAppFetchHandler({
1179
+ ...options,
1180
+ host: requestedHost,
1181
+ port: address.port,
1182
+ allowedHosts: defaultAllowedHosts(requestedHost, address.port)
1183
+ });
1184
+ return {
1185
+ host: requestedHost,
1186
+ port: address.port,
1187
+ url: `http://${formatHostAndPort(requestedHost, address.port)}`,
1188
+ close: async () => {
1189
+ await new Promise((resolvePromise, rejectPromise) => {
1190
+ server.close((error) => {
1191
+ if (error) {
1192
+ rejectPromise(error);
1193
+ return;
1194
+ }
1195
+ resolvePromise();
1196
+ });
1197
+ });
1198
+ }
1199
+ };
1200
+ }
1201
+ function toWebRequest(request, host, port) {
1202
+ const url = new URL(request.url ?? "/", `http://${formatHostAndPort(host, port)}`);
1203
+ const body = request.method === "GET" || request.method === "HEAD" ? void 0 : Readable.toWeb(request);
1204
+ const init = {
1205
+ method: request.method,
1206
+ headers: request.headers,
1207
+ body,
1208
+ duplex: body ? "half" : void 0
1209
+ };
1210
+ return new Request(url, init);
1211
+ }
1212
+ async function writeWebResponse(response, nodeResponse) {
1213
+ nodeResponse.statusCode = response.status;
1214
+ response.headers.forEach((value, key) => {
1215
+ nodeResponse.setHeader(key, value);
1216
+ });
1217
+ if (!response.body) {
1218
+ nodeResponse.end();
1219
+ return;
1220
+ }
1221
+ await new Promise((resolvePromise, rejectPromise) => {
1222
+ const stream = Readable.fromWeb(response.body);
1223
+ stream.on("error", rejectPromise);
1224
+ nodeResponse.on("finish", resolvePromise);
1225
+ nodeResponse.on("error", rejectPromise);
1226
+ stream.pipe(nodeResponse);
1227
+ });
1228
+ }
1229
+ function getListeningPort(server, fallbackPort) {
1230
+ const address = server.address();
1231
+ if (!address || typeof address === "string") {
1232
+ return fallbackPort;
1233
+ }
1234
+ return address.port;
1235
+ }
1236
+
1237
+ // src/cli.ts
1238
+ async function runCli(dependencies = {}) {
1239
+ const stdout = dependencies.stdout ?? process2.stdout;
1240
+ const stderr = dependencies.stderr ?? process2.stderr;
1241
+ const startServer = dependencies.startServer ?? startNodeServer;
1242
+ const openBrowser = dependencies.openBrowser ?? openDefaultBrowser;
1243
+ const packageMetadata = dependencies.packageMetadata ?? readPackageMetadata();
1244
+ const argv = dependencies.argv ?? process2.argv.slice(2);
1245
+ const parsed = parseCliOptions(argv);
1246
+ const help = renderHelp(packageMetadata.commandName, packageMetadata.name);
1247
+ if (!parsed.ok) {
1248
+ writeLine(stderr, parsed.error ?? "Invalid arguments.");
1249
+ writeLine(stderr, "");
1250
+ writeLine(stderr, help);
1251
+ return 1;
1252
+ }
1253
+ if (parsed.options.help) {
1254
+ writeLine(stdout, help);
1255
+ return 0;
1256
+ }
1257
+ if (parsed.options.version) {
1258
+ writeLine(stdout, packageMetadata.version);
1259
+ return 0;
1260
+ }
1261
+ let startedServer;
1262
+ try {
1263
+ startedServer = await startServer({
1264
+ dataDir: parsed.options.sourceRoot,
1265
+ distDir: resolvePackageDistDir(),
1266
+ port: parsed.options.port
1267
+ });
1268
+ } catch (error) {
1269
+ writeLine(stderr, error instanceof Error ? error.message : "Could not start session-visualizer.");
1270
+ return 1;
1271
+ }
1272
+ if (dependencies.attachSignalHandlers !== false) {
1273
+ attachShutdownHandlers(startedServer);
1274
+ }
1275
+ writeLine(stdout, `session-visualizer available at ${startedServer.url}`);
1276
+ if (!parsed.options.sourceRoot) {
1277
+ writeLine(stdout, "No source root configured. Open the URL, click Edit, and enter a directory path containing Pi session logs.");
1278
+ }
1279
+ if (parsed.options.open) {
1280
+ try {
1281
+ await openBrowser(startedServer.url);
1282
+ writeLine(stdout, "Opened the default browser.");
1283
+ } catch (error) {
1284
+ writeLine(stderr, `Warning: could not open the default browser: ${error instanceof Error ? error.message : "Unexpected error"}`);
1285
+ }
1286
+ }
1287
+ return 0;
1288
+ }
1289
+ async function openDefaultBrowser(url) {
1290
+ const child = process2.platform === "darwin" ? spawn("open", [url], { detached: true, stdio: "ignore" }) : process2.platform === "win32" ? spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }) : spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
1291
+ await new Promise((resolvePromise, rejectPromise) => {
1292
+ let settled = false;
1293
+ let fallbackTimeout = null;
1294
+ const cleanup = () => {
1295
+ if (fallbackTimeout) {
1296
+ clearTimeout(fallbackTimeout);
1297
+ }
1298
+ child.off("error", onError);
1299
+ child.off("spawn", onSpawn);
1300
+ child.off("close", onClose);
1301
+ };
1302
+ const settle = (callback) => {
1303
+ if (settled) {
1304
+ return;
1305
+ }
1306
+ settled = true;
1307
+ cleanup();
1308
+ callback();
1309
+ };
1310
+ const onError = (error) => {
1311
+ settle(() => rejectPromise(error));
1312
+ };
1313
+ const onSpawn = () => {
1314
+ child.unref();
1315
+ fallbackTimeout = setTimeout(() => {
1316
+ settle(resolvePromise);
1317
+ }, 1e3);
1318
+ };
1319
+ const onClose = (code) => {
1320
+ if (code === 0) {
1321
+ settle(resolvePromise);
1322
+ return;
1323
+ }
1324
+ settle(() => rejectPromise(new Error(`Browser opener exited with code ${code ?? "unknown"}.`)));
1325
+ };
1326
+ child.once("error", onError);
1327
+ child.once("spawn", onSpawn);
1328
+ child.once("close", onClose);
1329
+ });
1330
+ }
1331
+ function readPackageMetadata() {
1332
+ const packageJsonPath = join2(packageRootDir(), "package.json");
1333
+ const packageJson = JSON.parse(readFileSync2(packageJsonPath, "utf8"));
1334
+ const commandName = typeof packageJson.bin === "string" ? "session-visualizer" : Object.keys(packageJson.bin ?? {})[0] ?? "session-visualizer";
1335
+ return {
1336
+ name: packageJson.name ?? "@neiltron/session-visualizer",
1337
+ commandName,
1338
+ version: packageJson.version ?? "0.0.0"
1339
+ };
1340
+ }
1341
+ function resolvePackageDistDir() {
1342
+ return join2(packageRootDir(), "dist");
1343
+ }
1344
+ function packageRootDir() {
1345
+ return join2(dirname(fileURLToPath(import.meta.url)), "..");
1346
+ }
1347
+ function attachShutdownHandlers(startedServer) {
1348
+ const shutdown = async () => {
1349
+ try {
1350
+ await startedServer.close();
1351
+ } finally {
1352
+ process2.exit(0);
1353
+ }
1354
+ };
1355
+ process2.on("SIGINT", shutdown);
1356
+ process2.on("SIGTERM", shutdown);
1357
+ }
1358
+ function writeLine(stream, line) {
1359
+ stream.write(`${line}
1360
+ `);
1361
+ }
1362
+ var isMainModule = process2.argv[1] ? realpathSync(resolve3(process2.argv[1])) === realpathSync(fileURLToPath(import.meta.url)) : false;
1363
+ if (isMainModule) {
1364
+ const exitCode = await runCli();
1365
+ if (exitCode !== 0) {
1366
+ process2.exit(exitCode);
1367
+ }
1368
+ }
1369
+ export {
1370
+ runCli
1371
+ };