noctrace 0.6.1 → 0.7.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-Cm74Eldg.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-C4vi082v.css">
10
+ <script type="module" crossorigin src="/assets/index-BhUwV-5i.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-BWO5fecq.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -6,7 +6,7 @@ import { Router } from 'express';
6
6
  import fs from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import { WebSocket } from 'ws';
9
- import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, } from '../../shared/parser.js';
9
+ import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, parseInstructionsLoaded, } from '../../shared/parser.js';
10
10
  import { computeContextHealth } from '../../shared/health.js';
11
11
  import { parseAssistantTurns, computeDrift } from '../../shared/drift.js';
12
12
  import { attachEfficiencyTips } from '../../shared/tips.js';
@@ -66,6 +66,8 @@ function assertWithinBase(resolved, base) {
66
66
  export function buildApiRouter(claudeHome, wss) {
67
67
  const router = Router();
68
68
  const projectsDir = path.join(claudeHome, 'projects');
69
+ const teamsDir = path.join(claudeHome, 'teams');
70
+ const tasksDir = path.join(claudeHome, 'tasks');
69
71
  /**
70
72
  * In-memory registry of MCP-registered session paths.
71
73
  * Populated by POST /api/sessions/register; cleared on unregister or server restart.
@@ -167,6 +169,79 @@ export function buildApiRouter(claudeHome, wss) {
167
169
  }
168
170
  });
169
171
  // ---------------------------------------------------------------------------
172
+ // GET /api/teams
173
+ // ---------------------------------------------------------------------------
174
+ /**
175
+ * Scan ~/.claude/teams/ and return an array of AgentTeam objects.
176
+ * Returns an empty array if the teams directory doesn't exist.
177
+ * Each team includes its members (from config.json) and taskCount.
178
+ */
179
+ router.get('/teams', async (_req, res) => {
180
+ try {
181
+ let teamDirs;
182
+ try {
183
+ teamDirs = await fs.readdir(teamsDir);
184
+ }
185
+ catch {
186
+ // Teams directory doesn't exist — this is normal when the feature is unused
187
+ res.json([]);
188
+ return;
189
+ }
190
+ const teams = [];
191
+ for (const teamName of teamDirs) {
192
+ const teamPath = path.join(teamsDir, teamName);
193
+ let stat;
194
+ try {
195
+ stat = await fs.stat(teamPath);
196
+ }
197
+ catch {
198
+ continue;
199
+ }
200
+ if (!stat.isDirectory())
201
+ continue;
202
+ // Read team config.json
203
+ const configPath = path.join(teamPath, 'config.json');
204
+ let members = [];
205
+ try {
206
+ const configRaw = await fs.readFile(configPath, 'utf8');
207
+ const config = JSON.parse(configRaw);
208
+ const rawMembers = Array.isArray(config['members']) ? config['members'] : [];
209
+ for (const m of rawMembers) {
210
+ if (typeof m !== 'object' || m === null)
211
+ continue;
212
+ const member = m;
213
+ members.push({
214
+ name: typeof member['name'] === 'string' ? member['name'] : 'Unknown',
215
+ agentId: typeof member['agent_id'] === 'string' ? member['agent_id']
216
+ : typeof member['agentId'] === 'string' ? member['agentId'] : '',
217
+ agentType: typeof member['agent_type'] === 'string' ? member['agent_type']
218
+ : typeof member['agentType'] === 'string' ? member['agentType'] : '',
219
+ });
220
+ }
221
+ }
222
+ catch {
223
+ // config.json missing or malformed — include team with empty members
224
+ }
225
+ // Count task files in ~/.claude/tasks/{team-name}/
226
+ let taskCount = 0;
227
+ const teamTasksDir = path.join(tasksDir, teamName);
228
+ try {
229
+ const taskFiles = await fs.readdir(teamTasksDir);
230
+ taskCount = taskFiles.length;
231
+ }
232
+ catch {
233
+ // Task directory doesn't exist — taskCount stays 0
234
+ }
235
+ teams.push({ name: teamName, members, taskCount });
236
+ }
237
+ res.json(teams);
238
+ }
239
+ catch (err) {
240
+ const message = err instanceof Error ? err.message : String(err);
241
+ res.status(500).json({ error: message });
242
+ }
243
+ });
244
+ // ---------------------------------------------------------------------------
170
245
  // GET /api/sessions/:slug
171
246
  // ---------------------------------------------------------------------------
172
247
  /**
@@ -315,6 +390,7 @@ export function buildApiRouter(claudeHome, wss) {
315
390
  const sessionId = extractSessionId(content) ?? id;
316
391
  const turns = parseAssistantTurns(content);
317
392
  const drift = computeDrift(turns);
393
+ const instructionsLoaded = parseInstructionsLoaded(content);
318
394
  // Load sub-agent JSONL files and attach as children to matching agent rows
319
395
  const subagentsDir = path.join(projectsDir, slug, id, 'subagents');
320
396
  let subagentsDirExists = false;
@@ -377,7 +453,7 @@ export function buildApiRouter(claudeHome, wss) {
377
453
  return r.reduce((sum, row) => sum + row.tips.length + countTips(row.children), 0);
378
454
  }
379
455
  const tipCount = countTips(rows);
380
- res.json({ rows, compactionBoundaries: boundaries, health, sessionId, drift, tipCount });
456
+ res.json({ rows, compactionBoundaries: boundaries, health, sessionId, drift, tipCount, instructionsLoaded });
381
457
  }
382
458
  catch (err) {
383
459
  const message = err instanceof Error ? err.message : String(err);
@@ -139,7 +139,7 @@ function rowMatchesDirect(row, parsed) {
139
139
  // Type filter (OR among typeFilters)
140
140
  if (parsed.typeFilters.length > 0) {
141
141
  const rowTypeLower = row.toolName.toLowerCase();
142
- const rowKind = row.type; // 'agent' | 'tool'
142
+ const rowKind = row.type; // 'agent' | 'tool' | 'api-error'
143
143
  const matched = parsed.typeFilters.some((tf) => rowTypeLower === tf || rowTypeLower.includes(tf) || rowKind === tf);
144
144
  if (!matched)
145
145
  return false;
@@ -1,3 +1,4 @@
1
+ import { getPricing, computeCost } from './token-cost.js';
1
2
  // ---------------------------------------------------------------------------
2
3
  // Type guards and helpers
3
4
  // ---------------------------------------------------------------------------
@@ -70,6 +71,40 @@ function parseLine(line, idx) {
70
71
  function isAgent(name) {
71
72
  return name === 'Agent' || name === 'Task';
72
73
  }
74
+ /**
75
+ * Returns true when a tool result with is_error=true represents a tool execution
76
+ * failure (crash, timeout, permission denied) rather than a tool that ran and
77
+ * returned an error value. We distinguish by looking for crash/failure keywords
78
+ * in the output text.
79
+ */
80
+ function isToolFailure(output) {
81
+ const lower = output.toLowerCase();
82
+ return (lower.includes('process exited with') ||
83
+ lower.includes('timed out') ||
84
+ lower.includes('timeout') ||
85
+ lower.includes('permission denied') ||
86
+ lower.includes('killed') ||
87
+ lower.includes('segmentation fault') ||
88
+ lower.includes('posttoolusedfailure') ||
89
+ lower.includes('tool execution failed') ||
90
+ lower.includes('failed to execute'));
91
+ }
92
+ /**
93
+ * Classify an API stop failure message into a short error class label.
94
+ * Returns e.g. "Rate Limit", "Billing Error", "Server Error", "Auth Error".
95
+ */
96
+ function classifyStopFailure(message) {
97
+ const lower = message.toLowerCase();
98
+ if (lower.includes('rate limit') || lower.includes('rate_limit') || lower.includes('429'))
99
+ return 'Rate Limit';
100
+ if (lower.includes('billing') || lower.includes('payment') || lower.includes('credit'))
101
+ return 'Billing Error';
102
+ if (lower.includes('auth') || lower.includes('unauthorized') || lower.includes('403') || lower.includes('401'))
103
+ return 'Auth Error';
104
+ if (lower.includes('overloaded') || lower.includes('529'))
105
+ return 'Overloaded';
106
+ return 'Server Error';
107
+ }
73
108
  // ---------------------------------------------------------------------------
74
109
  // Public API
75
110
  // ---------------------------------------------------------------------------
@@ -100,11 +135,15 @@ export function parseJsonlContent(content) {
100
135
  for (const block of c) {
101
136
  if (isObj(block) && block['type'] === 'tool_result' && typeof block['tool_use_id'] === 'string') {
102
137
  const tb = block;
138
+ const output = extractContent(tb.content);
139
+ const isError = tb.is_error === true;
103
140
  resultMap.set(tb.tool_use_id, {
104
141
  endTime,
105
- output: extractContent(tb.content),
106
- isError: tb.is_error === true,
142
+ output,
143
+ isError,
144
+ isFailure: isError && isToolFailure(output),
107
145
  totalDurationMs: ur.toolUseResult?.totalDurationMs,
146
+ agentType: ur.toolUseResult?.agentType,
108
147
  });
109
148
  }
110
149
  }
@@ -199,10 +238,12 @@ export function parseJsonlContent(content) {
199
238
  const ar = rec;
200
239
  const startTime = new Date(ar.timestamp).getTime();
201
240
  const usage = ar.message.usage;
202
- const inputTokens = (usage?.input_tokens ?? 0)
203
- + (usage?.cache_creation_input_tokens ?? 0)
204
- + (usage?.cache_read_input_tokens ?? 0);
241
+ const cacheReadTokens = usage?.cache_read_input_tokens ?? 0;
242
+ const cacheCreateTokens = usage?.cache_creation_input_tokens ?? 0;
243
+ const rawInputTokens = usage?.input_tokens ?? 0;
244
+ const inputTokens = rawInputTokens + cacheCreateTokens + cacheReadTokens;
205
245
  const outputTokens = usage?.output_tokens ?? 0;
246
+ const modelName = typeof ar.message.model === 'string' ? ar.message.model : null;
206
247
  const contextFillPercent = 0; // recalculated after peak detection
207
248
  for (const block of ar.message.content) {
208
249
  if (!isToolUse(block))
@@ -234,6 +275,9 @@ export function parseJsonlContent(content) {
234
275
  output: res ? res.output : null,
235
276
  inputTokens,
236
277
  outputTokens,
278
+ cacheReadTokens,
279
+ cacheCreateTokens,
280
+ modelName,
237
281
  contextFillPercent,
238
282
  isReread,
239
283
  assistantUuid: ar.uuid,
@@ -275,6 +319,12 @@ export function parseJsonlContent(content) {
275
319
  // First pass: create rows from top-level assistant records
276
320
  const rowById = new Map();
277
321
  for (const p of pending) {
322
+ const pricing = getPricing(p.modelName);
323
+ const rawInput = Math.max(0, p.inputTokens - p.cacheReadTokens - p.cacheCreateTokens);
324
+ const estimatedCost = p.inputTokens > 0 || p.outputTokens > 0
325
+ ? computeCost(pricing, rawInput, p.outputTokens, p.cacheReadTokens, p.cacheCreateTokens)
326
+ : null;
327
+ const res = resultMap.get(p.id);
278
328
  rowById.set(p.id, {
279
329
  id: p.id,
280
330
  type: isAgent(p.toolName) ? 'agent' : 'tool',
@@ -292,8 +342,12 @@ export function parseJsonlContent(content) {
292
342
  tokenDelta: 0,
293
343
  contextFillPercent: p.contextFillPercent,
294
344
  isReread: p.isReread,
345
+ isFailure: res?.isFailure ?? false,
295
346
  children: [],
296
347
  tips: [],
348
+ modelName: p.modelName,
349
+ estimatedCost,
350
+ agentType: res?.agentType ?? null,
297
351
  });
298
352
  }
299
353
  // Create rows from sub-agent progress records
@@ -326,8 +380,12 @@ export function parseJsonlContent(content) {
326
380
  tokenDelta: 0,
327
381
  contextFillPercent: ctxFill,
328
382
  isReread,
383
+ isFailure: res ? (res.isError && isToolFailure(res.output)) : false,
329
384
  children: [],
330
385
  tips: [],
386
+ modelName: null,
387
+ estimatedCost: null,
388
+ agentType: null,
331
389
  });
332
390
  }
333
391
  // Nest children: top-level rows use parentUuid ancestry, sub-agent rows use parentToolUseID
@@ -404,6 +462,52 @@ export function parseJsonlContent(content) {
404
462
  }
405
463
  }
406
464
  computeDeltas(top);
465
+ // Detect API stop failures from system records (rate limit, billing, auth, server errors).
466
+ // These appear as system records with subtype 'stop_failure' or similar.
467
+ // We insert them as top-level 'api-error' rows at their point in time.
468
+ for (const rec of records) {
469
+ if (rec.type !== 'system')
470
+ continue;
471
+ const sr = rec;
472
+ const subtype = sr.subtype ?? '';
473
+ const isStopFailure = subtype === 'stop_failure' ||
474
+ subtype === 'api_error' ||
475
+ subtype === 'request_failed';
476
+ if (!isStopFailure)
477
+ continue;
478
+ const ts = new Date(sr.timestamp).getTime();
479
+ // Try to extract an error message from the record's top-level fields.
480
+ const raw = rec;
481
+ const errorMsg = (typeof raw['error'] === 'string' ? raw['error'] : null) ||
482
+ (typeof raw['message'] === 'string' ? raw['message'] : null) ||
483
+ subtype;
484
+ const errorClass = classifyStopFailure(errorMsg);
485
+ const rowId = `api-error-${sr.uuid}`;
486
+ top.push({
487
+ id: rowId,
488
+ type: 'api-error',
489
+ toolName: errorClass,
490
+ label: errorMsg,
491
+ startTime: ts,
492
+ endTime: ts,
493
+ duration: 0,
494
+ status: 'error',
495
+ parentAgentId: null,
496
+ input: {},
497
+ output: errorMsg,
498
+ inputTokens: 0,
499
+ outputTokens: 0,
500
+ tokenDelta: 0,
501
+ contextFillPercent: 0,
502
+ isReread: false,
503
+ isFailure: false,
504
+ children: [],
505
+ tips: [],
506
+ modelName: null,
507
+ estimatedCost: null,
508
+ agentType: null,
509
+ });
510
+ }
407
511
  return top;
408
512
  }
409
513
  /**
@@ -487,10 +591,13 @@ export function parseSubAgentContent(content) {
487
591
  for (const block of c) {
488
592
  if (isObj(block) && block['type'] === 'tool_result' && typeof block['tool_use_id'] === 'string') {
489
593
  const tb = block;
594
+ const output = extractContent(tb.content);
595
+ const isError = tb.is_error === true;
490
596
  resultMap.set(tb.tool_use_id, {
491
597
  endTime,
492
- output: extractContent(tb.content),
493
- isError: tb.is_error === true,
598
+ output,
599
+ isError,
600
+ isFailure: isError && isToolFailure(output),
494
601
  });
495
602
  }
496
603
  }
@@ -517,11 +624,15 @@ export function parseSubAgentContent(content) {
517
624
  const ar = rec;
518
625
  const startTime = new Date(ar.timestamp).getTime();
519
626
  const usage = ar.message.usage;
520
- const inputTokens = (usage?.input_tokens ?? 0)
521
- + (usage?.cache_creation_input_tokens ?? 0)
522
- + (usage?.cache_read_input_tokens ?? 0);
627
+ const cacheReadTokens = usage?.cache_read_input_tokens ?? 0;
628
+ const cacheCreateTokens = usage?.cache_creation_input_tokens ?? 0;
629
+ const rawInputTokens = usage?.input_tokens ?? 0;
630
+ const inputTokens = rawInputTokens + cacheCreateTokens + cacheReadTokens;
523
631
  const outputTokens = usage?.output_tokens ?? 0;
632
+ const modelName = typeof ar.message.model === 'string' ? ar.message.model : null;
524
633
  const contextFillPercent = (inputTokens / effectiveWindow) * 100;
634
+ const pricing = getPricing(modelName);
635
+ const rawInput = Math.max(0, rawInputTokens);
525
636
  for (const block of ar.message.content) {
526
637
  if (!isToolUse(block))
527
638
  continue;
@@ -530,6 +641,9 @@ export function parseSubAgentContent(content) {
530
641
  const isReread = fp !== null && seenPaths.has(fp);
531
642
  if (fp !== null)
532
643
  seenPaths.add(fp);
644
+ const estimatedCost = inputTokens > 0 || outputTokens > 0
645
+ ? computeCost(pricing, rawInput, outputTokens, cacheReadTokens, cacheCreateTokens)
646
+ : null;
533
647
  const res = resultMap.get(block.id);
534
648
  rows.push({
535
649
  id: block.id,
@@ -548,11 +662,57 @@ export function parseSubAgentContent(content) {
548
662
  tokenDelta: 0,
549
663
  contextFillPercent,
550
664
  isReread,
665
+ isFailure: res?.isFailure ?? false,
551
666
  children: [],
552
667
  tips: [],
668
+ modelName,
669
+ estimatedCost,
670
+ agentType: null,
553
671
  });
554
672
  }
555
673
  }
674
+ // Detect API stop failures within sub-agent content
675
+ for (const rec of records) {
676
+ if (rec.type !== 'system')
677
+ continue;
678
+ const sr = rec;
679
+ const subtype = sr.subtype ?? '';
680
+ const isStopFailure = subtype === 'stop_failure' ||
681
+ subtype === 'api_error' ||
682
+ subtype === 'request_failed';
683
+ if (!isStopFailure)
684
+ continue;
685
+ const ts = new Date(sr.timestamp).getTime();
686
+ const raw = rec;
687
+ const errorMsg = (typeof raw['error'] === 'string' ? raw['error'] : null) ||
688
+ (typeof raw['message'] === 'string' ? raw['message'] : null) ||
689
+ subtype;
690
+ const errorClass = classifyStopFailure(errorMsg);
691
+ rows.push({
692
+ id: `api-error-${sr.uuid}`,
693
+ type: 'api-error',
694
+ toolName: errorClass,
695
+ label: errorMsg,
696
+ startTime: ts,
697
+ endTime: ts,
698
+ duration: 0,
699
+ status: 'error',
700
+ parentAgentId: null,
701
+ input: {},
702
+ output: errorMsg,
703
+ inputTokens: 0,
704
+ outputTokens: 0,
705
+ tokenDelta: 0,
706
+ contextFillPercent: 0,
707
+ isReread: false,
708
+ isFailure: false,
709
+ children: [],
710
+ tips: [],
711
+ modelName: null,
712
+ estimatedCost: null,
713
+ agentType: null,
714
+ });
715
+ }
556
716
  // Compute per-row token delta for sub-agent rows
557
717
  const sorted = [...rows].sort((a, b) => a.startTime - b.startTime);
558
718
  let prevInput = 0;
@@ -563,6 +723,85 @@ export function parseSubAgentContent(content) {
563
723
  }
564
724
  return rows;
565
725
  }
726
+ /**
727
+ * Parse instruction-loading records from JSONL content.
728
+ * Looks for system records with subtype containing "instructions" or similar patterns,
729
+ * as well as user records with isMeta=true that describe loaded CLAUDE.md files.
730
+ * Returns a deduplicated list of InstructionFile entries. Never throws.
731
+ */
732
+ export function parseInstructionsLoaded(content) {
733
+ const lines = content.split('\n');
734
+ const seen = new Set();
735
+ const result = [];
736
+ for (let i = 0; i < lines.length; i++) {
737
+ const line = lines[i].trim();
738
+ if (!line)
739
+ continue;
740
+ let parsed;
741
+ try {
742
+ parsed = JSON.parse(line);
743
+ }
744
+ catch {
745
+ continue;
746
+ }
747
+ if (!isObj(parsed))
748
+ continue;
749
+ const recordType = parsed['type'];
750
+ const subtype = typeof parsed['subtype'] === 'string' ? parsed['subtype'] : null;
751
+ // Match system records that describe loaded instruction files
752
+ if (recordType === 'system') {
753
+ // Claude Code may emit system records with subtype: "instructions_loaded",
754
+ // "claude_md_loaded", "context_loaded", or similar
755
+ const isInstructionRecord = subtype !== null && (subtype.includes('instruction') ||
756
+ subtype.includes('claude_md') ||
757
+ subtype.includes('context_load') ||
758
+ subtype.includes('system_prompt'));
759
+ if (isInstructionRecord) {
760
+ const filePath = typeof parsed['filePath'] === 'string' ? parsed['filePath']
761
+ : typeof parsed['file_path'] === 'string' ? parsed['file_path']
762
+ : typeof parsed['path'] === 'string' ? parsed['path']
763
+ : null;
764
+ if (!filePath || seen.has(filePath))
765
+ continue;
766
+ seen.add(filePath);
767
+ const loadReason = typeof parsed['reason'] === 'string' ? parsed['reason']
768
+ : typeof parsed['load_reason'] === 'string' ? parsed['load_reason']
769
+ : subtype ?? 'session_start';
770
+ const estimatedTokens = typeof parsed['tokens'] === 'number' ? parsed['tokens']
771
+ : typeof parsed['token_count'] === 'number' ? parsed['token_count']
772
+ : null;
773
+ const parentFilePath = typeof parsed['parentFilePath'] === 'string' ? parsed['parentFilePath']
774
+ : typeof parsed['parent_file_path'] === 'string' ? parsed['parent_file_path']
775
+ : null;
776
+ result.push({ filePath, loadReason, estimatedTokens, parentFilePath });
777
+ continue;
778
+ }
779
+ }
780
+ // Match user records with isMeta=true that list loaded context files
781
+ if (recordType === 'user' && parsed['isMeta'] === true) {
782
+ const metaContent = parsed['content'];
783
+ const contentStr = typeof metaContent === 'string' ? metaContent : '';
784
+ if (!contentStr)
785
+ continue;
786
+ // Look for patterns like "Loaded CLAUDE.md from /path/to/CLAUDE.md"
787
+ // or "Instructions loaded: /path/to/CLAUDE.md"
788
+ const filePathMatches = contentStr.matchAll(/(?:loaded|reading|including)[:\s]+([^\s,\n]+\.md)/gi);
789
+ for (const match of filePathMatches) {
790
+ const filePath = match[1];
791
+ if (!filePath || seen.has(filePath))
792
+ continue;
793
+ seen.add(filePath);
794
+ result.push({
795
+ filePath,
796
+ loadReason: 'session_start',
797
+ estimatedTokens: null,
798
+ parentFilePath: null,
799
+ });
800
+ }
801
+ }
802
+ }
803
+ return result;
804
+ }
566
805
  /**
567
806
  * Extract a mapping of Agent/Task tool_use IDs to sub-agent IDs from session content.
568
807
  * Returns a Map where keys are the parent tool_use.id values and values are agentId strings
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Token cost estimation utilities for Claude models.
3
+ * Pricing is based on Claude's public API pricing (USD per million tokens).
4
+ */
5
+ /** Sonnet pricing (default) */
6
+ const SONNET_PRICING = {
7
+ inputPerMTok: 3.00,
8
+ outputPerMTok: 15.00,
9
+ cacheReadPerMTok: 0.30,
10
+ cacheCreatePerMTok: 3.75,
11
+ };
12
+ /** Opus pricing */
13
+ const OPUS_PRICING = {
14
+ inputPerMTok: 15.00,
15
+ outputPerMTok: 75.00,
16
+ cacheReadPerMTok: 1.50,
17
+ cacheCreatePerMTok: 18.75,
18
+ };
19
+ /** Haiku pricing */
20
+ const HAIKU_PRICING = {
21
+ inputPerMTok: 0.80,
22
+ outputPerMTok: 4.00,
23
+ cacheReadPerMTok: 0.08,
24
+ cacheCreatePerMTok: 1.00,
25
+ };
26
+ /**
27
+ * Returns pricing for a given model name.
28
+ * Defaults to Sonnet pricing when the model is null or unrecognized.
29
+ */
30
+ export function getPricing(modelName) {
31
+ if (!modelName)
32
+ return SONNET_PRICING;
33
+ const lower = modelName.toLowerCase();
34
+ if (lower.includes('opus'))
35
+ return OPUS_PRICING;
36
+ if (lower.includes('haiku'))
37
+ return HAIKU_PRICING;
38
+ // Sonnet and any unknown model default to Sonnet pricing
39
+ return SONNET_PRICING;
40
+ }
41
+ /**
42
+ * Computes the estimated cost in USD for a single assistant turn.
43
+ *
44
+ * @param pricing - The per-MTok pricing for the model
45
+ * @param input - Raw input tokens (non-cached)
46
+ * @param output - Output tokens
47
+ * @param cacheRead - Cache read tokens (default 0)
48
+ * @param cacheCreate - Cache creation tokens (default 0)
49
+ * @returns Estimated cost in USD
50
+ */
51
+ export function computeCost(pricing, input, output, cacheRead = 0, cacheCreate = 0) {
52
+ const M = 1_000_000;
53
+ return ((input * pricing.inputPerMTok) / M +
54
+ (output * pricing.outputPerMTok) / M +
55
+ (cacheRead * pricing.cacheReadPerMTok) / M +
56
+ (cacheCreate * pricing.cacheCreatePerMTok) / M);
57
+ }
58
+ /**
59
+ * Formats a cost value in USD to a human-readable string.
60
+ * Uses fixed 4 decimal places for very small values, 2 for larger ones.
61
+ */
62
+ export function formatCost(cost) {
63
+ if (cost >= 1)
64
+ return `$${cost.toFixed(2)}`;
65
+ if (cost >= 0.01)
66
+ return `$${cost.toFixed(3)}`;
67
+ return `$${cost.toFixed(4)}`;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noctrace",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Chrome DevTools Network-tab-style waterfall visualizer for Claude Code agent workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,2 +0,0 @@
1
- /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
2
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-sm:.25rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-8{top:calc(var(--spacing) * 8)}.right-0{right:calc(var(--spacing) * 0)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.left-0{left:calc(var(--spacing) * 0)}.left-2{left:calc(var(--spacing) * 2)}.z-10{z-index:10}.z-50{z-index:50}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-full{height:100%}.w-full{width:100%}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.flex-col{flex-direction:column}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-sm{border-radius:var(--radius-sm)}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-7{padding-left:calc(var(--spacing) * 7)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.uppercase{text-transform:uppercase}.italic{font-style:italic}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.\[file\:line\]{file:line}@media (width>=48rem){.md\:hidden{display:none}}}:root{--ctp-base:#1e1e2e;--ctp-mantle:#181825;--ctp-crust:#11111b;--ctp-surface0:#313244;--ctp-surface1:#45475a;--ctp-surface2:#585b70;--ctp-overlay0:#6c7086;--ctp-overlay1:#7f849c;--ctp-text:#cdd6f4;--ctp-subtext0:#a6adc8;--ctp-subtext1:#bac2de;--ctp-blue:#89b4fa;--ctp-green:#a6e3a1;--ctp-yellow:#f9e2af;--ctp-peach:#fab387;--ctp-mauve:#cba6f7;--ctp-teal:#94e2d5;--ctp-red:#f38ba8;--ctp-pink:#f5c2e7;--ctp-lavender:#b4befe;--color-read:var(--ctp-blue);--color-write:var(--ctp-green);--color-edit:var(--ctp-yellow);--color-bash:var(--ctp-peach);--color-agent:var(--ctp-mauve);--color-grep:var(--ctp-teal);--color-error:var(--ctp-red);--color-running:var(--ctp-pink)}body{background-color:var(--ctp-base);color:var(--ctp-text);font-family:SF Mono,Cascadia Code,JetBrains Mono,Fira Code,ui-monospace,monospace}@keyframes pulse-edge{0%,to{opacity:.6}50%{opacity:.1}}.running-pulse{animation:1.2s ease-in-out infinite pulse-edge}@keyframes noc-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.85)}}@keyframes noc-blink{0%,to{opacity:1}50%{opacity:0}}@media (width<=768px){.hidden-mobile{display:none!important}}.sidebar-panel{background-color:var(--ctp-mantle);flex-direction:column;flex-shrink:0;width:240px;display:flex;overflow:hidden}@media (width<=767px){.sidebar-panel{width:240px;transition:transform .2s;position:fixed;top:0;bottom:0;left:0;transform:translate(-100%)}.sidebar-panel[data-open=true]{transform:translate(0)}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}