noctrace 0.7.4 → 0.8.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.
@@ -7,8 +7,8 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-BPKebIZj.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DyjeNSzP.css">
10
+ <script type="module" crossorigin src="/assets/index-x60cSMi2.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-DwPuae45.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -7,10 +7,12 @@ import fs from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import { WebSocket } from 'ws';
9
9
  import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, parseInstructionsLoaded, } from '../../shared/parser.js';
10
+ import { parseSessionResultMetrics, parseInitContext } from '../../shared/session-metadata.js';
10
11
  import { computeContextHealth } from '../../shared/health.js';
11
12
  import { parseAssistantTurns, computeDrift } from '../../shared/drift.js';
12
13
  import { attachEfficiencyTips } from '../../shared/tips.js';
13
14
  import { attachSecurityTips } from '../../shared/security-tips.js';
15
+ import { sessionToOtlp } from '../../shared/otlp-export.js';
14
16
  /**
15
17
  * Read ~/.claude/sessions/*.json and return a Set of sessionIds
16
18
  * whose PID is still a running claude process.
@@ -222,17 +224,34 @@ export function buildApiRouter(claudeHome, wss) {
222
224
  catch {
223
225
  // config.json missing or malformed — include team with empty members
224
226
  }
225
- // Count task files in ~/.claude/tasks/{team-name}/
227
+ // Count task files and parse their contents from ~/.claude/tasks/{team-name}/
226
228
  let taskCount = 0;
229
+ const tasks = [];
227
230
  const teamTasksDir = path.join(tasksDir, teamName);
228
231
  try {
229
232
  const taskFiles = await fs.readdir(teamTasksDir);
230
233
  taskCount = taskFiles.length;
234
+ for (const tf of taskFiles) {
235
+ if (!tf.endsWith('.json'))
236
+ continue;
237
+ try {
238
+ const taskRaw = await fs.readFile(path.join(teamTasksDir, tf), 'utf8');
239
+ const taskData = JSON.parse(taskRaw);
240
+ tasks.push({
241
+ id: tf.replace(/\.json$/, ''),
242
+ subject: typeof taskData['subject'] === 'string' ? taskData['subject'] : tf,
243
+ status: typeof taskData['status'] === 'string' ? taskData['status'] : 'pending',
244
+ assignedTo: typeof taskData['assigned_to'] === 'string' ? taskData['assigned_to']
245
+ : typeof taskData['assignedTo'] === 'string' ? taskData['assignedTo'] : null,
246
+ });
247
+ }
248
+ catch { /* skip malformed task files */ }
249
+ }
231
250
  }
232
251
  catch {
233
252
  // Task directory doesn't exist — taskCount stays 0
234
253
  }
235
- teams.push({ name: teamName, members, taskCount });
254
+ teams.push({ name: teamName, members, taskCount, tasks });
236
255
  }
237
256
  res.json(teams);
238
257
  }
@@ -382,6 +401,43 @@ export function buildApiRouter(claudeHome, wss) {
382
401
  }
383
402
  });
384
403
  // ---------------------------------------------------------------------------
404
+ // GET /api/session/:slug/:id/otlp (MUST be before :slug/:id to avoid param capture)
405
+ // ---------------------------------------------------------------------------
406
+ /**
407
+ * Export a session as OTLP/HTTP JSON trace format.
408
+ * The response can be POSTed directly to any OTLP collector at /v1/traces.
409
+ */
410
+ router.get('/session/:slug/:id/otlp', async (req, res) => {
411
+ const { slug, id } = req.params;
412
+ const filePath = path.join(projectsDir, slug, `${id}.jsonl`);
413
+ try {
414
+ assertWithinBase(filePath, projectsDir);
415
+ }
416
+ catch {
417
+ res.status(400).json({ error: 'Invalid path' });
418
+ return;
419
+ }
420
+ try {
421
+ let content;
422
+ try {
423
+ content = await fs.readFile(filePath, 'utf8');
424
+ }
425
+ catch {
426
+ res.status(404).json({ error: `Session not found: ${slug}/${id}` });
427
+ return;
428
+ }
429
+ const rows = parseJsonlContent(content);
430
+ const sessionId = extractSessionId(content) ?? id;
431
+ const otlp = sessionToOtlp(rows, sessionId);
432
+ res.setHeader('Content-Type', 'application/json');
433
+ res.json(otlp);
434
+ }
435
+ catch (err) {
436
+ const message = err instanceof Error ? err.message : String(err);
437
+ res.status(500).json({ error: message });
438
+ }
439
+ });
440
+ // ---------------------------------------------------------------------------
385
441
  // GET /api/session/:slug/:id
386
442
  // ---------------------------------------------------------------------------
387
443
  /**
@@ -414,6 +470,8 @@ export function buildApiRouter(claudeHome, wss) {
414
470
  const turns = parseAssistantTurns(content);
415
471
  const drift = computeDrift(turns);
416
472
  const instructionsLoaded = parseInstructionsLoaded(content);
473
+ const resultMetrics = parseSessionResultMetrics(content);
474
+ const initContext = parseInitContext(content);
417
475
  // Load sub-agent JSONL files and attach as children to matching agent rows
418
476
  const subagentsDir = path.join(projectsDir, slug, id, 'subagents');
419
477
  let subagentsDirExists = false;
@@ -468,7 +526,8 @@ export function buildApiRouter(claudeHome, wss) {
468
526
  }
469
527
  }
470
528
  // Attach efficiency tips to wasteful rows (mutates rows in place)
471
- attachEfficiencyTips(rows, boundaries);
529
+ // tips.ts expects number[] (timestamps), so extract from CompactionBoundary[]
530
+ attachEfficiencyTips(rows, boundaries.map((b) => b.timestamp));
472
531
  // Attach security tips (mutates rows in place)
473
532
  attachSecurityTips(rows);
474
533
  // Count total tips across all rows (including children) for the client toolbar
@@ -476,7 +535,7 @@ export function buildApiRouter(claudeHome, wss) {
476
535
  return r.reduce((sum, row) => sum + row.tips.length + countTips(row.children), 0);
477
536
  }
478
537
  const tipCount = countTips(rows);
479
- res.json({ rows, compactionBoundaries: boundaries, health, sessionId, drift, tipCount, instructionsLoaded });
538
+ res.json({ rows, compactionBoundaries: boundaries, health, sessionId, drift, tipCount, instructionsLoaded, resultMetrics, initContext });
480
539
  }
481
540
  catch (err) {
482
541
  const message = err instanceof Error ? err.message : String(err);
@@ -509,6 +568,22 @@ export function buildApiRouter(claudeHome, wss) {
509
568
  ...(typeof body['permission_mode'] === 'string' ? { permission_mode: body['permission_mode'] } : {}),
510
569
  received_at: new Date().toISOString(),
511
570
  };
571
+ // For SubagentStart events, broadcast a separate message type
572
+ // so the client can show an in-progress agent row immediately
573
+ if (event.hook_event_name === 'SubagentStart' && event.agent_id) {
574
+ const subagentMsg = {
575
+ type: 'subagent-start',
576
+ agentId: event.agent_id,
577
+ agentType: event.agent_type ?? null,
578
+ sessionId: event.session_id,
579
+ };
580
+ const subPayload = JSON.stringify(subagentMsg);
581
+ for (const client of wss.clients) {
582
+ if (client.readyState === WebSocket.OPEN) {
583
+ client.send(subPayload);
584
+ }
585
+ }
586
+ }
512
587
  const message = { type: 'hook-event', event };
513
588
  const payload = JSON.stringify(message);
514
589
  for (const client of wss.clients) {
@@ -67,7 +67,8 @@ export function watchSession(filePath, callbacks) {
67
67
  const turns = parseAssistantTurns(fullContent);
68
68
  const drift = computeDrift(turns);
69
69
  // Attach efficiency tips to wasteful rows (mutates allRows in place)
70
- attachEfficiencyTips(allRows, boundaries);
70
+ // tips.ts expects number[] (timestamps), so extract from CompactionBoundary[]
71
+ attachEfficiencyTips(allRows, boundaries.map((b) => b.timestamp));
71
72
  // Attach security tips (mutates allRows in place)
72
73
  attachSecurityTips(allRows);
73
74
  callbacks.onNewRows(allRows, health, boundaries, drift);
@@ -151,7 +151,7 @@ export function setupWebSocket(server, claudeHome) {
151
151
  try {
152
152
  const proc = spawn('claude', args, {
153
153
  stdio: ['ignore', 'pipe', 'pipe'],
154
- env: { ...process.env },
154
+ env: { PATH: process.env['PATH'] ?? '', HOME: process.env['HOME'] ?? '', CLAUDE_HOME: process.env['CLAUDE_HOME'] ?? '' },
155
155
  });
156
156
  resumeProc = proc;
157
157
  // Buffer for incomplete lines from chunked TCP data
@@ -0,0 +1,116 @@
1
+ /** OTLP span status code for OK */
2
+ const STATUS_OK = 1;
3
+ /** OTLP span status code for ERROR */
4
+ const STATUS_ERROR = 2;
5
+ /** Convert Unix ms timestamp to OTLP nanosecond string */
6
+ function toNanos(ms) {
7
+ return (BigInt(ms) * BigInt(1_000_000)).toString();
8
+ }
9
+ /** Generate a 16-byte hex trace ID from session ID */
10
+ function traceIdFromSession(sessionId) {
11
+ const hex = sessionId.replace(/[^a-f0-9]/gi, '');
12
+ return (hex + '0'.repeat(32)).slice(0, 32);
13
+ }
14
+ /** Generate an 8-byte hex span ID from row ID */
15
+ function spanIdFromRow(rowId) {
16
+ const hex = rowId.replace(/[^a-f0-9]/gi, '');
17
+ return (hex + '0'.repeat(16)).slice(0, 16);
18
+ }
19
+ function strAttr(key, value) {
20
+ return { key, value: { stringValue: value } };
21
+ }
22
+ function intAttr(key, value) {
23
+ return { key, value: { intValue: String(value) } };
24
+ }
25
+ function boolAttr(key, value) {
26
+ return { key, value: { boolValue: value } };
27
+ }
28
+ function doubleAttr(key, value) {
29
+ return { key, value: { doubleValue: value } };
30
+ }
31
+ /** Convert a WaterfallRow to an OTLP span object */
32
+ function rowToSpan(row, sessionId, parentSpanId) {
33
+ const traceId = traceIdFromSession(sessionId);
34
+ const spanId = spanIdFromRow(row.id);
35
+ const endTime = row.endTime ?? row.startTime;
36
+ const attributes = [
37
+ strAttr('tool.name', row.toolName),
38
+ strAttr('tool.type', row.type),
39
+ strAttr('tool.label', row.label),
40
+ strAttr('tool.status', row.status),
41
+ intAttr('token.input', row.inputTokens),
42
+ intAttr('token.output', row.outputTokens),
43
+ intAttr('token.delta', row.tokenDelta),
44
+ doubleAttr('context.fill_percent', row.contextFillPercent),
45
+ boolAttr('tool.is_reread', row.isReread),
46
+ boolAttr('tool.is_failure', row.isFailure),
47
+ ];
48
+ if (row.modelName)
49
+ attributes.push(strAttr('model.name', row.modelName));
50
+ if (row.estimatedCost != null)
51
+ attributes.push(doubleAttr('cost.usd', row.estimatedCost));
52
+ if (row.agentType)
53
+ attributes.push(strAttr('agent.type', row.agentType));
54
+ if (row.isFastMode)
55
+ attributes.push(boolAttr('model.fast_mode', true));
56
+ if (row.duration != null)
57
+ attributes.push(intAttr('duration_ms', row.duration));
58
+ const span = {
59
+ traceId,
60
+ spanId,
61
+ name: row.type === 'agent' ? `agent.${row.toolName}` : `tool.${row.toolName}`,
62
+ kind: 1, // SPAN_KIND_INTERNAL
63
+ startTimeUnixNano: toNanos(row.startTime),
64
+ endTimeUnixNano: toNanos(endTime),
65
+ attributes,
66
+ status: {
67
+ code: row.status === 'error' ? STATUS_ERROR : STATUS_OK,
68
+ ...(row.status === 'error' && row.output ? { message: row.output.slice(0, 256) } : {}),
69
+ },
70
+ };
71
+ if (parentSpanId) {
72
+ span['parentSpanId'] = parentSpanId;
73
+ }
74
+ return span;
75
+ }
76
+ /** Recursively flatten rows and their children into a flat array of OTLP spans */
77
+ function flattenSpans(rows, sessionId, parentSpanId) {
78
+ const spans = [];
79
+ for (const row of rows) {
80
+ const spanId = spanIdFromRow(row.id);
81
+ spans.push(rowToSpan(row, sessionId, parentSpanId));
82
+ if (row.children.length > 0) {
83
+ spans.push(...flattenSpans(row.children, sessionId, spanId));
84
+ }
85
+ }
86
+ return spans;
87
+ }
88
+ /**
89
+ * Convert a noctrace session to OTLP/HTTP JSON trace export format.
90
+ * The output can be POSTed directly to any OTLP/HTTP collector at /v1/traces.
91
+ *
92
+ * @param rows - Parsed waterfall rows from parseJsonlContent()
93
+ * @param sessionId - Session UUID used to derive the OTLP trace ID
94
+ */
95
+ export function sessionToOtlp(rows, sessionId) {
96
+ const spans = flattenSpans(rows, sessionId, null);
97
+ return {
98
+ resourceSpans: [
99
+ {
100
+ resource: {
101
+ attributes: [
102
+ strAttr('service.name', 'noctrace'),
103
+ strAttr('service.version', '0.7.5'),
104
+ strAttr('session.id', sessionId),
105
+ ],
106
+ },
107
+ scopeSpans: [
108
+ {
109
+ scope: { name: 'noctrace', version: '0.7.5' },
110
+ spans,
111
+ },
112
+ ],
113
+ },
114
+ ],
115
+ };
116
+ }
@@ -1,4 +1,6 @@
1
1
  import { getPricing, computeCost } from './token-cost.js';
2
+ // Re-export from session-metadata (moved to reduce file size)
3
+ export { parseCompactionBoundaries } from './session-metadata.js';
2
4
  // ---------------------------------------------------------------------------
3
5
  // Type guards and helpers
4
6
  // ---------------------------------------------------------------------------
@@ -64,7 +66,7 @@ function parseLine(line, idx) {
64
66
  return null;
65
67
  }
66
68
  const type = parsed['type'];
67
- if (type !== 'assistant' && type !== 'user' && type !== 'system' && type !== 'progress')
69
+ if (type !== 'assistant' && type !== 'user' && type !== 'system' && type !== 'progress' && type !== 'result')
68
70
  return null;
69
71
  return parsed;
70
72
  }
@@ -105,6 +107,22 @@ function classifyStopFailure(message) {
105
107
  return 'Overloaded';
106
108
  return 'Server Error';
107
109
  }
110
+ /**
111
+ * Map structured assistant.error field to a display label.
112
+ * Falls back to classifyStopFailure for unknown values.
113
+ */
114
+ function classifyAssistantError(errorField) {
115
+ const map = {
116
+ rate_limit: 'Rate Limit',
117
+ billing_error: 'Billing Error',
118
+ authentication_failed: 'Auth Error',
119
+ server_error: 'Server Error',
120
+ invalid_request: 'Invalid Request',
121
+ max_output_tokens: 'Max Tokens',
122
+ unknown: 'Server Error',
123
+ };
124
+ return map[errorField] ?? classifyStopFailure(errorField);
125
+ }
108
126
  // ---------------------------------------------------------------------------
109
127
  // Public API
110
128
  // ---------------------------------------------------------------------------
@@ -245,6 +263,9 @@ export function parseJsonlContent(content) {
245
263
  const inputTokens = rawInputTokens + cacheCreateTokens + cacheReadTokens;
246
264
  const outputTokens = usage?.output_tokens ?? 0;
247
265
  const modelName = typeof ar.message.model === 'string' ? ar.message.model : null;
266
+ const isFastMode = ar.message.speed === 'fast';
267
+ const parentToolUseId = typeof ar.parent_tool_use_id === 'string' ? ar.parent_tool_use_id : null;
268
+ const sequence = typeof ar.sequence === 'number' ? ar.sequence : null;
248
269
  const contextFillPercent = 0; // recalculated after peak detection
249
270
  for (const block of ar.message.content) {
250
271
  if (!isToolUse(block))
@@ -283,6 +304,9 @@ export function parseJsonlContent(content) {
283
304
  isReread,
284
305
  assistantUuid: ar.uuid,
285
306
  assistantParentUuid: ar.parentUuid,
307
+ sequence,
308
+ isFastMode,
309
+ parentToolUseId,
286
310
  });
287
311
  }
288
312
  }
@@ -350,6 +374,9 @@ export function parseJsonlContent(content) {
350
374
  estimatedCost,
351
375
  agentType: res?.agentType ?? null,
352
376
  agentColor: res?.agentColor ?? null,
377
+ sequence: p.sequence,
378
+ isFastMode: p.isFastMode,
379
+ parentToolUseId: p.parentToolUseId,
353
380
  });
354
381
  }
355
382
  // Create rows from sub-agent progress records
@@ -389,6 +416,9 @@ export function parseJsonlContent(content) {
389
416
  estimatedCost: null,
390
417
  agentType: null,
391
418
  agentColor: null,
419
+ sequence: null,
420
+ isFastMode: false,
421
+ parentToolUseId: null,
392
422
  });
393
423
  }
394
424
  // Nest children: top-level rows use parentUuid ancestry, sub-agent rows use parentToolUseID
@@ -422,7 +452,7 @@ export function parseJsonlContent(content) {
422
452
  // Sort children by startTime within each agent
423
453
  for (const row of rowById.values()) {
424
454
  if (row.children.length > 1) {
425
- row.children.sort((a, b) => a.startTime - b.startTime);
455
+ row.children.sort((a, b) => a.startTime !== b.startTime ? a.startTime - b.startTime : (a.sequence ?? 0) - (b.sequence ?? 0));
426
456
  }
427
457
  }
428
458
  // Stretch agent rows to span from dispatch to last child completion
@@ -510,23 +540,103 @@ export function parseJsonlContent(content) {
510
540
  estimatedCost: null,
511
541
  agentType: null,
512
542
  agentColor: null,
543
+ sequence: null,
544
+ isFastMode: false,
545
+ parentToolUseId: null,
513
546
  });
514
547
  }
515
- return top;
516
- }
517
- /**
518
- * Extract Unix ms timestamps of compact_boundary system records.
519
- */
520
- export function parseCompactionBoundaries(content) {
521
- const lines = content.split('\n');
522
- const out = [];
523
- for (let i = 0; i < lines.length; i++) {
524
- const r = parseLine(lines[i], i);
525
- if (r && r.type === 'system' && r.subtype === 'compact_boundary') {
526
- out.push(new Date(r.timestamp).getTime());
548
+ // Detect API errors from typed assistant.error field (newer Claude Code versions)
549
+ for (const rec of records) {
550
+ if (rec.type !== 'assistant')
551
+ continue;
552
+ const ar = rec;
553
+ if (!ar.message.error)
554
+ continue;
555
+ const ts = new Date(ar.timestamp).getTime();
556
+ const errorClass = classifyAssistantError(ar.message.error);
557
+ const rowId = `api-error-asst-${ar.uuid}`;
558
+ // Skip if a system-record api-error already exists at this timestamp (avoid duplicates)
559
+ if (top.some((r) => r.type === 'api-error' && Math.abs(r.startTime - ts) < 1000))
560
+ continue;
561
+ top.push({
562
+ id: rowId,
563
+ type: 'api-error',
564
+ toolName: errorClass,
565
+ label: ar.message.error,
566
+ startTime: ts,
567
+ endTime: ts,
568
+ duration: 0,
569
+ status: 'error',
570
+ parentAgentId: null,
571
+ input: {},
572
+ output: ar.message.error,
573
+ inputTokens: 0,
574
+ outputTokens: 0,
575
+ tokenDelta: 0,
576
+ contextFillPercent: 0,
577
+ isReread: false,
578
+ isFailure: false,
579
+ children: [],
580
+ tips: [],
581
+ modelName: null,
582
+ estimatedCost: null,
583
+ agentType: null,
584
+ agentColor: null,
585
+ sequence: typeof ar.sequence === 'number' ? ar.sequence : null,
586
+ isFastMode: false,
587
+ parentToolUseId: null,
588
+ });
589
+ }
590
+ // Detect hook lifecycle events from system records
591
+ const hookStartMap = new Map(); // hookKey → startTime
592
+ for (const rec of records) {
593
+ if (rec.type !== 'system')
594
+ continue;
595
+ const sr = rec;
596
+ const subtype = sr.subtype ?? '';
597
+ if (subtype !== 'hook_started' && subtype !== 'hook_response')
598
+ continue;
599
+ const raw = rec;
600
+ const hookName = typeof raw['hook_name'] === 'string' ? raw['hook_name'] : subtype;
601
+ const hookId = typeof raw['hook_id'] === 'string' ? raw['hook_id'] : sr.uuid;
602
+ const ts = new Date(sr.timestamp).getTime();
603
+ if (subtype === 'hook_started') {
604
+ hookStartMap.set(hookId, ts);
605
+ }
606
+ else if (subtype === 'hook_response') {
607
+ const startTs = hookStartMap.get(hookId) ?? ts;
608
+ const duration = ts - startTs;
609
+ top.push({
610
+ id: `hook-${hookId}`,
611
+ type: 'hook',
612
+ toolName: hookName,
613
+ label: `Hook: ${hookName}`,
614
+ startTime: startTs,
615
+ endTime: ts,
616
+ duration,
617
+ status: 'success',
618
+ parentAgentId: null,
619
+ input: {},
620
+ output: null,
621
+ inputTokens: 0,
622
+ outputTokens: 0,
623
+ tokenDelta: 0,
624
+ contextFillPercent: 0,
625
+ isReread: false,
626
+ isFailure: false,
627
+ children: [],
628
+ tips: [],
629
+ modelName: null,
630
+ estimatedCost: null,
631
+ agentType: null,
632
+ agentColor: null,
633
+ sequence: typeof sr.sequence === 'number' ? sr.sequence : null,
634
+ isFastMode: false,
635
+ parentToolUseId: null,
636
+ });
527
637
  }
528
638
  }
529
- return out;
639
+ return top;
530
640
  }
531
641
  /**
532
642
  * Extract the sessionId from the first valid record. Returns null if none found.
@@ -673,6 +783,9 @@ export function parseSubAgentContent(content) {
673
783
  estimatedCost,
674
784
  agentType: null,
675
785
  agentColor: null,
786
+ sequence: null,
787
+ isFastMode: false,
788
+ parentToolUseId: null,
676
789
  });
677
790
  }
678
791
  }
@@ -717,6 +830,9 @@ export function parseSubAgentContent(content) {
717
830
  estimatedCost: null,
718
831
  agentType: null,
719
832
  agentColor: null,
833
+ sequence: null,
834
+ isFastMode: false,
835
+ parentToolUseId: null,
720
836
  });
721
837
  }
722
838
  // Compute per-row token delta for sub-agent rows
@@ -784,7 +900,7 @@ export function parseInstructionsLoaded(content) {
784
900
  }
785
901
  }
786
902
  // Match user records with isMeta=true that list loaded context files
787
- if (recordType === 'user' && parsed['isMeta'] === true) {
903
+ if (recordType === 'user' && (parsed['isMeta'] === true || parsed['isSynthetic'] === true)) {
788
904
  const metaContent = parsed['content'];
789
905
  const contentStr = typeof metaContent === 'string' ? metaContent : '';
790
906
  if (!contentStr)