noctrace 0.4.2 → 0.5.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,7 +7,7 @@
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-t0_dKSyq.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-CfD3iTQS.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-nE1IMGzA.css">
12
12
  </head>
13
13
  <body>
@@ -6,10 +6,11 @@ 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, extractAgentIds, parseSubAgentContent, } from '../../shared/parser.js';
9
+ import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, } 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';
13
+ import { attachSecurityTips } from '../../shared/security-tips.js';
13
14
  /**
14
15
  * Read ~/.claude/sessions/*.json and return a Set of sessionIds
15
16
  * whose PID is still a running claude process.
@@ -193,9 +194,11 @@ export function buildApiRouter(claudeHome, wss) {
193
194
  let permissionMode = null;
194
195
  let isRemoteControlled = false;
195
196
  let isActive = false;
197
+ let sessionTitle = null;
198
+ let fileContent = null;
196
199
  try {
197
- const content = await fs.readFile(filePath, 'utf8');
198
- const lines = content.split('\n');
200
+ fileContent = await fs.readFile(filePath, 'utf8');
201
+ const lines = fileContent.split('\n');
199
202
  // Extract metadata from lines (scan first 50 for speed)
200
203
  const scanLimit = Math.min(lines.length, 50);
201
204
  for (let i = 0; i < scanLimit; i++) {
@@ -226,13 +229,15 @@ export function buildApiRouter(claudeHome, wss) {
226
229
  // Active if: live process in registry OR file modified within last 2 minutes
227
230
  // Registry covers CLI sessions; mtime covers Desktop app sessions
228
231
  isActive = runningSessions.has(id) || (Date.now() - stat.mtime.getTime() < 120_000);
232
+ // Extract optional session title — scans all lines, best-effort
233
+ sessionTitle = extractSessionTitle(fileContent);
229
234
  }
230
235
  catch {
231
236
  // Unreadable file — still include with null startTime
232
237
  }
233
238
  let driftFactor = null;
234
239
  try {
235
- const content = await fs.readFile(filePath, 'utf8');
240
+ const content = fileContent ?? await fs.readFile(filePath, 'utf8');
236
241
  const sessionTurns = parseAssistantTurns(content);
237
242
  const sessionDrift = computeDrift(sessionTurns);
238
243
  driftFactor = sessionTurns.length >= 5 ? sessionDrift.driftFactor : null;
@@ -251,6 +256,7 @@ export function buildApiRouter(claudeHome, wss) {
251
256
  permissionMode,
252
257
  isRemoteControlled,
253
258
  driftFactor,
259
+ title: sessionTitle,
254
260
  });
255
261
  }
256
262
  // Sort by lastModified descending (most recent first)
@@ -349,6 +355,8 @@ export function buildApiRouter(claudeHome, wss) {
349
355
  }
350
356
  // Attach efficiency tips to wasteful rows (mutates rows in place)
351
357
  attachEfficiencyTips(rows, boundaries);
358
+ // Attach security tips (mutates rows in place)
359
+ attachSecurityTips(rows);
352
360
  // Count total tips across all rows (including children) for the client toolbar
353
361
  function countTips(r) {
354
362
  return r.reduce((sum, row) => sum + row.tips.length + countTips(row.children), 0);
@@ -8,6 +8,7 @@ import { parseJsonlContent, parseCompactionBoundaries } from '../shared/parser.j
8
8
  import { computeContextHealth } from '../shared/health.js';
9
9
  import { parseAssistantTurns, computeDrift } from '../shared/drift.js';
10
10
  import { attachEfficiencyTips } from '../shared/tips.js';
11
+ import { attachSecurityTips } from '../shared/security-tips.js';
11
12
  /**
12
13
  * Watch a single JSONL session file for appended content.
13
14
  * On each file change, reads only the newly appended bytes, parses them into
@@ -67,6 +68,8 @@ export function watchSession(filePath, callbacks) {
67
68
  const drift = computeDrift(turns);
68
69
  // Attach efficiency tips to wasteful rows (mutates allRows in place)
69
70
  attachEfficiencyTips(allRows, boundaries);
71
+ // Attach security tips (mutates allRows in place)
72
+ attachSecurityTips(allRows);
70
73
  callbacks.onNewRows(allRows, health, boundaries, drift);
71
74
  }
72
75
  catch (err) {
@@ -206,6 +206,7 @@ export function computeContextHealth(rows, compactionCount) {
206
206
  score: 100,
207
207
  fillPercent: 0,
208
208
  compactionCount: 0,
209
+ compactionThrash: false,
209
210
  rereadRatio: 0,
210
211
  errorAcceleration: 1,
211
212
  toolEfficiency: 1,
@@ -230,6 +231,7 @@ export function computeContextHealth(rows, compactionCount) {
230
231
  score: Math.round(composite),
231
232
  fillPercent,
232
233
  compactionCount,
234
+ compactionThrash: compactionCount >= 3,
233
235
  rereadRatio,
234
236
  errorAcceleration,
235
237
  toolEfficiency,
@@ -432,6 +432,36 @@ export function extractSessionId(content) {
432
432
  }
433
433
  return null;
434
434
  }
435
+ /**
436
+ * Extract a human-readable session title from JSONL content.
437
+ * Checks top-level fields `sessionTitle`, `title`, and `displayName` on any record,
438
+ * and also inspects system records for a `title` field.
439
+ * Returns null if no title is found.
440
+ */
441
+ export function extractSessionTitle(content) {
442
+ const lines = content.split('\n');
443
+ for (let i = 0; i < lines.length; i++) {
444
+ const line = lines[i].trim();
445
+ if (!line)
446
+ continue;
447
+ let parsed;
448
+ try {
449
+ parsed = JSON.parse(line);
450
+ }
451
+ catch {
452
+ continue;
453
+ }
454
+ if (!isObj(parsed))
455
+ continue;
456
+ // Check top-level title fields in priority order
457
+ for (const field of ['sessionTitle', 'title', 'displayName']) {
458
+ const val = parsed[field];
459
+ if (typeof val === 'string' && val.trim())
460
+ return val.trim();
461
+ }
462
+ }
463
+ return null;
464
+ }
435
465
  /**
436
466
  * Parse a sub-agent JSONL file into flat WaterfallRow objects.
437
467
  * Does not attempt agent nesting — all tool calls are returned as a flat array.
@@ -0,0 +1,315 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Pre-compiled regex constants
3
+ // ---------------------------------------------------------------------------
4
+ /** Matches common API key / secret token patterns. */
5
+ const SECRET_PATTERNS = [
6
+ /AKIA[0-9A-Z]{16}/,
7
+ /sk-[a-zA-Z0-9]{20,}/,
8
+ /sk-ant-[a-zA-Z0-9-]{20,}/,
9
+ /ghp_[a-zA-Z0-9]{36}/,
10
+ /npm_[a-zA-Z0-9]{36}/,
11
+ /xoxb-[0-9]{10,}/,
12
+ /-----BEGIN .{0,30}PRIVATE KEY-----/,
13
+ ];
14
+ /** curl/wget piped directly to a shell interpreter. */
15
+ const CURL_PIPE_BASH = /curl[^|]*\|[^|]*(bash|sh|zsh|python|node)\b|wget[^|]*\|[^|]*(bash|sh)\b|eval\s*\$\(curl/;
16
+ /** Outbound data transfer via curl/wget/nc. */
17
+ const DATA_EXFIL = /curl[^&\n]*(-d\s|--data|--data-raw|-F\s)|cat[^|]*\|[^|]*(curl|wget|nc)\b|base64[^|]*\|[^|]*(curl|wget)\b/;
18
+ /** localhost / loopback exclusion for data exfiltration. */
19
+ const LOCALHOST_PATTERN = /localhost|127\.0\.0\.1/;
20
+ /** Write/Edit to shell profile files. */
21
+ const SHELL_PROFILE = /\.(bashrc|zshrc|bash_profile|zprofile|profile)$/;
22
+ /** Zero-width and bidirectional Unicode control characters. */
23
+ const HIDDEN_UNICODE = /[\u200B\u200C\u200D\u2060\uFEFF\u202A-\u202E]/;
24
+ /** Individual prompt-injection keyword patterns. */
25
+ const INJECTION_KEYWORDS = [
26
+ /ignore.*previous.*instructions/i,
27
+ /disregard.*above/i,
28
+ /you are now/i,
29
+ /new instructions:/i,
30
+ /system prompt/i,
31
+ ];
32
+ /** Destructive shell / SQL commands. */
33
+ const DESTRUCTIVE_CMD = /rm\s+.*-.*r.*-.*f|rm\s+-rf|DROP\s+(TABLE|DATABASE)|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\w+\s*;/i;
34
+ /** git push --force (but NOT --force-with-lease). */
35
+ const FORCE_PUSH = /git\s+push\s+.*--force(?!-with-lease)|git\s+push\s+.*-f\b/;
36
+ /** Sensitive credential / config file paths. */
37
+ const SENSITIVE_FILE = /\.env($|\.)|\.pem$|\.key$|id_rsa|id_ed25519|\.ssh\/|credentials\.json|\.aws\/|\.npmrc|\.docker\/config|kubeconfig/;
38
+ /** Overly permissive chmod. */
39
+ const PERMISSION_WEAKENING = /chmod\s+(777|666)|chmod\s+-R\s+7/;
40
+ /** sudo invocation at the start of a command. */
41
+ const SUDO_CMD = /^sudo\s/;
42
+ /** Binary file downloaded from the internet. */
43
+ const BINARY_DOWNLOAD = /curl[^&\n]*-o[^&\n]*\.(sh|bin|exe)|wget[^&\n]*\.(sh|bin|exe|deb|rpm)/;
44
+ /** Tool names that operate on files (Write / Edit / MultiEdit). */
45
+ const FILE_WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
46
+ /** Maximum number of output characters to scan (performance guard). */
47
+ const OUTPUT_SCAN_LIMIT = 3000;
48
+ // ---------------------------------------------------------------------------
49
+ // Internal helpers
50
+ // ---------------------------------------------------------------------------
51
+ /**
52
+ * Append a tip to a row, skipping duplicates by tip id.
53
+ */
54
+ function addTip(row, tip) {
55
+ if (!row.tips.some((t) => t.id === tip.id)) {
56
+ row.tips.push(tip);
57
+ }
58
+ }
59
+ /**
60
+ * Return the first OUTPUT_SCAN_LIMIT characters of a string, or '' if null.
61
+ */
62
+ function truncatedOutput(output) {
63
+ if (output === null)
64
+ return '';
65
+ return output.length > OUTPUT_SCAN_LIMIT ? output.slice(0, OUTPUT_SCAN_LIMIT) : output;
66
+ }
67
+ /**
68
+ * Return true if any SECRET_PATTERNS regex matches the given text.
69
+ */
70
+ function containsSecret(text) {
71
+ return SECRET_PATTERNS.some((re) => re.test(text));
72
+ }
73
+ /**
74
+ * Return true if the output text appears to contain prompt-injection patterns.
75
+ * Requires 2+ distinct keyword matches within 200 characters of each other.
76
+ * Skips Markdown documentation files (too many false positives).
77
+ */
78
+ function hasPossibleInjection(output, filePath) {
79
+ if (filePath !== null && filePath.endsWith('.md'))
80
+ return false;
81
+ const matches = [];
82
+ for (const re of INJECTION_KEYWORDS) {
83
+ const m = re.exec(output);
84
+ if (m !== null)
85
+ matches.push(m.index);
86
+ }
87
+ if (matches.length < 2)
88
+ return false;
89
+ // Check whether any two match positions are within 200 chars of each other.
90
+ const sorted = matches.slice().sort((a, b) => a - b);
91
+ for (let i = 0; i < sorted.length - 1; i++) {
92
+ if (sorted[i + 1] - sorted[i] <= 200)
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+ /**
98
+ * Flatten rows and all their children into a single ordered array.
99
+ */
100
+ function flattenRows(rows) {
101
+ const result = [];
102
+ for (const row of rows) {
103
+ result.push(row);
104
+ if (row.children.length > 0) {
105
+ result.push(...flattenRows(row.children));
106
+ }
107
+ }
108
+ return result;
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // Public API
112
+ // ---------------------------------------------------------------------------
113
+ /**
114
+ * Scan rows for security-relevant patterns and attach tips. Mutates rows in place.
115
+ *
116
+ * Detection rules (CRITICAL):
117
+ * 1. secrets-in-output — credential patterns in tool output
118
+ * 2. secrets-in-command — credential patterns in Bash input.command
119
+ * 3. curl-pipe-bash — remote code execution via pipe
120
+ * 4. data-exfiltration — outbound data transfer (excluding localhost)
121
+ * 5. shell-profile-mod — Write/Edit to .bashrc / .zshrc etc.
122
+ * 6. hidden-unicode — zero-width / bidirectional chars in output
123
+ * 7. prompt-injection — instruction-like text in tool output
124
+ *
125
+ * Detection rules (WARNING):
126
+ * 8. destructive-command — rm -rf, DROP TABLE, DELETE without WHERE
127
+ * 9. force-push — git push --force (not --force-with-lease)
128
+ * 10. sensitive-file — .env, .pem, .key, .ssh/, AWS/npm credentials
129
+ * 11. permission-weakening — chmod 777/666
130
+ * 12. sudo-usage — sudo at the start of a command
131
+ *
132
+ * Detection rules (INFO):
133
+ * 13. binary-download — curl/wget downloading .sh / .bin / .exe
134
+ *
135
+ * @param rows Top-level WaterfallRow[] (may include agent rows with children).
136
+ */
137
+ export function attachSecurityTips(rows) {
138
+ const flat = flattenRows(rows);
139
+ for (const row of flat) {
140
+ if (row.type !== 'tool')
141
+ continue;
142
+ const output = truncatedOutput(row.output);
143
+ const command = row.toolName === 'Bash' && typeof row.input['command'] === 'string'
144
+ ? row.input['command']
145
+ : null;
146
+ const filePath = FILE_WRITE_TOOLS.has(row.toolName) && typeof row.input['file_path'] === 'string'
147
+ ? row.input['file_path']
148
+ : null;
149
+ const readFilePath = row.toolName === 'Read' && typeof row.input['file_path'] === 'string'
150
+ ? row.input['file_path']
151
+ : null;
152
+ const anyFilePath = filePath ?? readFilePath;
153
+ // ------------------------------------------------------------------
154
+ // 1. secrets-in-output (CRITICAL)
155
+ // ------------------------------------------------------------------
156
+ if (output && containsSecret(output)) {
157
+ addTip(row, {
158
+ id: 'secrets-in-output',
159
+ title: 'Secret detected in output',
160
+ message: 'An API key or secret token appeared in this tool output. It is now in your session log on disk. ' +
161
+ 'Rotate the credential and add the source file to .gitignore.',
162
+ severity: 'critical',
163
+ category: 'security',
164
+ });
165
+ }
166
+ // ------------------------------------------------------------------
167
+ // 2. secrets-in-command (CRITICAL)
168
+ // ------------------------------------------------------------------
169
+ if (command !== null && containsSecret(command)) {
170
+ addTip(row, {
171
+ id: 'secrets-in-command',
172
+ title: 'Secret in command',
173
+ message: 'A credential appears to be hardcoded in this shell command. Use environment variables instead.',
174
+ severity: 'critical',
175
+ category: 'security',
176
+ });
177
+ }
178
+ // ------------------------------------------------------------------
179
+ // 3. curl-pipe-bash (CRITICAL)
180
+ // ------------------------------------------------------------------
181
+ if (command !== null && CURL_PIPE_BASH.test(command)) {
182
+ addTip(row, {
183
+ id: 'curl-pipe-bash',
184
+ title: 'Remote code execution',
185
+ message: 'Downloading and piping directly to a shell interpreter. This bypasses security checks. ' +
186
+ 'Review the URL and consider using a package manager.',
187
+ severity: 'critical',
188
+ category: 'security',
189
+ });
190
+ }
191
+ // ------------------------------------------------------------------
192
+ // 4. data-exfiltration (CRITICAL)
193
+ // ------------------------------------------------------------------
194
+ if (command !== null && DATA_EXFIL.test(command) && !LOCALHOST_PATTERN.test(command)) {
195
+ addTip(row, {
196
+ id: 'data-exfiltration',
197
+ title: 'Outbound data transfer',
198
+ message: 'Data is being sent to an external URL. Verify the destination and confirm no sensitive data is being transmitted.',
199
+ severity: 'critical',
200
+ category: 'security',
201
+ });
202
+ }
203
+ // ------------------------------------------------------------------
204
+ // 5. shell-profile-mod (CRITICAL)
205
+ // ------------------------------------------------------------------
206
+ if (filePath !== null && SHELL_PROFILE.test(filePath)) {
207
+ addTip(row, {
208
+ id: 'shell-profile-mod',
209
+ title: 'Shell profile modified',
210
+ message: 'Changes to shell profiles persist across all future terminal sessions. Review what was added.',
211
+ severity: 'critical',
212
+ category: 'security',
213
+ });
214
+ }
215
+ // ------------------------------------------------------------------
216
+ // 6. hidden-unicode (CRITICAL)
217
+ // ------------------------------------------------------------------
218
+ if (output && HIDDEN_UNICODE.test(output)) {
219
+ addTip(row, {
220
+ id: 'hidden-unicode',
221
+ title: 'Hidden Unicode characters',
222
+ message: 'Invisible control characters detected in tool output. These can conceal instructions that ' +
223
+ 'Claude processes but humans cannot see.',
224
+ severity: 'critical',
225
+ category: 'security',
226
+ });
227
+ }
228
+ // ------------------------------------------------------------------
229
+ // 7. prompt-injection (CRITICAL)
230
+ // ------------------------------------------------------------------
231
+ if (output && hasPossibleInjection(output, anyFilePath)) {
232
+ addTip(row, {
233
+ id: 'prompt-injection',
234
+ title: 'Possible prompt injection',
235
+ message: 'Tool output contains instruction-like text that could manipulate Claude. This may indicate ' +
236
+ 'adversarial content in project files or dependencies.',
237
+ severity: 'critical',
238
+ category: 'security',
239
+ });
240
+ }
241
+ // ------------------------------------------------------------------
242
+ // 8. destructive-command (WARNING)
243
+ // ------------------------------------------------------------------
244
+ if (command !== null && DESTRUCTIVE_CMD.test(command)) {
245
+ addTip(row, {
246
+ id: 'destructive-command',
247
+ title: 'Destructive command',
248
+ message: 'This command can cause irreversible data loss. Verify the target is correct.',
249
+ severity: 'warning',
250
+ category: 'security',
251
+ });
252
+ }
253
+ // ------------------------------------------------------------------
254
+ // 9. force-push (WARNING)
255
+ // ------------------------------------------------------------------
256
+ if (command !== null && FORCE_PUSH.test(command)) {
257
+ addTip(row, {
258
+ id: 'force-push',
259
+ title: 'Force push',
260
+ message: 'Force push rewrites remote history. Use --force-with-lease for safer pushes, or verify this is intentional.',
261
+ severity: 'warning',
262
+ category: 'security',
263
+ });
264
+ }
265
+ // ------------------------------------------------------------------
266
+ // 10. sensitive-file (WARNING) — Read, Write, Edit, MultiEdit
267
+ // ------------------------------------------------------------------
268
+ const sensitiveFilePath = typeof row.input['file_path'] === 'string' ? row.input['file_path'] : null;
269
+ if (sensitiveFilePath !== null && SENSITIVE_FILE.test(sensitiveFilePath)) {
270
+ addTip(row, {
271
+ id: 'sensitive-file',
272
+ title: 'Sensitive file accessed',
273
+ message: 'Credential file contents are now in the session log (~/.claude/projects/). Consider whether this access was necessary.',
274
+ severity: 'warning',
275
+ category: 'security',
276
+ });
277
+ }
278
+ // ------------------------------------------------------------------
279
+ // 11. permission-weakening (WARNING)
280
+ // ------------------------------------------------------------------
281
+ if (command !== null && PERMISSION_WEAKENING.test(command)) {
282
+ addTip(row, {
283
+ id: 'permission-weakening',
284
+ title: 'Overly permissive permissions',
285
+ message: 'World-writable permissions (777/666). Use 755 for directories, 644 for files.',
286
+ severity: 'warning',
287
+ category: 'security',
288
+ });
289
+ }
290
+ // ------------------------------------------------------------------
291
+ // 12. sudo-usage (WARNING)
292
+ // ------------------------------------------------------------------
293
+ if (command !== null && SUDO_CMD.test(command)) {
294
+ addTip(row, {
295
+ id: 'sudo-usage',
296
+ title: 'Root privilege escalation',
297
+ message: 'Claude should not need sudo for development tasks. Consider a non-root approach.',
298
+ severity: 'warning',
299
+ category: 'security',
300
+ });
301
+ }
302
+ // ------------------------------------------------------------------
303
+ // 13. binary-download (INFO)
304
+ // ------------------------------------------------------------------
305
+ if (command !== null && BINARY_DOWNLOAD.test(command)) {
306
+ addTip(row, {
307
+ id: 'binary-download',
308
+ title: 'Binary download',
309
+ message: 'Binary downloaded from the internet. Verify the source URL and consider using a package manager with signature verification.',
310
+ severity: 'info',
311
+ category: 'security',
312
+ });
313
+ }
314
+ }
315
+ }
@@ -43,6 +43,7 @@ function flattenRows(rows) {
43
43
  * 6. High context fill — first row where contextFillPercent >= 80
44
44
  * 7. No delegation — 50+ total tool rows with zero agent rows
45
45
  * 8. Post-compaction re-read — isReread after a compaction boundary
46
+ * 9. Compaction thrash — 3+ compaction boundaries (thrash loop)
46
47
  *
47
48
  * A row may accumulate multiple tips. Duplicate tip ids on the same row are silently skipped.
48
49
  *
@@ -206,4 +207,28 @@ export function attachEfficiencyTips(rows, compactionBoundaries) {
206
207
  severity: 'info',
207
208
  });
208
209
  }
210
+ // -------------------------------------------------------------------------
211
+ // Rule 9: Compaction thrash (post-scan)
212
+ // Triggered when the session has been compacted 3+ times.
213
+ // Attach the tip to the row nearest (at or after) the 3rd compaction boundary.
214
+ // -------------------------------------------------------------------------
215
+ if (sortedBoundaries.length >= 3) {
216
+ const thirdBoundary = sortedBoundaries[2];
217
+ // Find the first tool row whose startTime is at or after the 3rd boundary.
218
+ let targetRow = flat.find((r) => r.type === 'tool' && r.startTime >= thirdBoundary);
219
+ // If no row comes after (session ended right at compaction), use the last tool row.
220
+ if (targetRow === undefined && toolRows.length > 0) {
221
+ targetRow = toolRows[toolRows.length - 1];
222
+ }
223
+ if (targetRow !== undefined) {
224
+ addTip(targetRow, {
225
+ id: 'compaction-thrash',
226
+ title: 'Compaction thrash loop',
227
+ message: 'This session has been compacted 3+ times, meaning context fills up immediately after each ' +
228
+ 'compaction. Start a new session with /clear, or break your work into smaller tasks. ' +
229
+ 'Add persistent context to CLAUDE.md so it survives compaction.',
230
+ severity: 'critical',
231
+ });
232
+ }
233
+ }
209
234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noctrace",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Chrome DevTools Network-tab-style waterfall visualizer for Claude Code agent workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",