noctrace 0.2.0 → 0.3.1

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-qeBZVwft.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DKj34--U.css">
10
+ <script type="module" crossorigin src="/assets/index-Cfpt-dCZ.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-B8NjnKEc.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -7,6 +7,7 @@ import fs from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractAgentIds, parseSubAgentContent, } from '../../shared/parser';
9
9
  import { computeContextHealth } from '../../shared/health';
10
+ import { parseAssistantTurns, computeDrift } from '../../shared/drift';
10
11
  /**
11
12
  * Read ~/.claude/sessions/*.json and return a Set of sessionIds
12
13
  * whose PID is still a running claude process.
@@ -46,6 +47,14 @@ async function getRunningSessionIds(claudeHome) {
46
47
  }
47
48
  return running;
48
49
  }
50
+ /** Validate that a resolved path is within the allowed base directory. */
51
+ function assertWithinBase(resolved, base) {
52
+ const normalizedResolved = path.resolve(resolved);
53
+ const normalizedBase = path.resolve(base);
54
+ if (!normalizedResolved.startsWith(normalizedBase + path.sep) && normalizedResolved !== normalizedBase) {
55
+ throw new Error('Path traversal detected');
56
+ }
57
+ }
49
58
  /** Build the Express router, scoped to a given Claude home directory. */
50
59
  export function buildApiRouter(claudeHome) {
51
60
  const router = Router();
@@ -144,6 +153,13 @@ export function buildApiRouter(claudeHome) {
144
153
  router.get('/sessions/:slug', async (req, res) => {
145
154
  const { slug } = req.params;
146
155
  const projectDir = path.join(projectsDir, slug);
156
+ try {
157
+ assertWithinBase(projectDir, projectsDir);
158
+ }
159
+ catch {
160
+ res.status(400).json({ error: 'Invalid path' });
161
+ return;
162
+ }
147
163
  try {
148
164
  let files;
149
165
  try {
@@ -200,7 +216,7 @@ export function buildApiRouter(claudeHome) {
200
216
  isRemoteControlled = true;
201
217
  }
202
218
  }
203
- rowCount = parseJsonlContent(content).length;
219
+ rowCount = lines.filter((l) => l.trim()).length;
204
220
  // Active if: live process in registry OR file modified within last 2 minutes
205
221
  // Registry covers CLI sessions; mtime covers Desktop app sessions
206
222
  isActive = runningSessions.has(id) || (Date.now() - stat.mtime.getTime() < 120_000);
@@ -208,6 +224,16 @@ export function buildApiRouter(claudeHome) {
208
224
  catch {
209
225
  // Unreadable file — still include with null startTime
210
226
  }
227
+ let driftFactor = null;
228
+ try {
229
+ const content = await fs.readFile(filePath, 'utf8');
230
+ const sessionTurns = parseAssistantTurns(content);
231
+ const sessionDrift = computeDrift(sessionTurns);
232
+ driftFactor = sessionTurns.length >= 5 ? sessionDrift.driftFactor : null;
233
+ }
234
+ catch {
235
+ // Drift computation is best-effort — don't fail the session listing
236
+ }
211
237
  sessions.push({
212
238
  id,
213
239
  projectSlug: slug,
@@ -218,6 +244,7 @@ export function buildApiRouter(claudeHome) {
218
244
  isActive,
219
245
  permissionMode,
220
246
  isRemoteControlled,
247
+ driftFactor,
221
248
  });
222
249
  }
223
250
  // Sort by lastModified descending (most recent first)
@@ -239,6 +266,13 @@ export function buildApiRouter(claudeHome) {
239
266
  router.get('/session/:slug/:id', async (req, res) => {
240
267
  const { slug, id } = req.params;
241
268
  const filePath = path.join(projectsDir, slug, `${id}.jsonl`);
269
+ try {
270
+ assertWithinBase(filePath, projectsDir);
271
+ }
272
+ catch {
273
+ res.status(400).json({ error: 'Invalid path' });
274
+ return;
275
+ }
242
276
  try {
243
277
  let content;
244
278
  try {
@@ -252,6 +286,8 @@ export function buildApiRouter(claudeHome) {
252
286
  const boundaries = parseCompactionBoundaries(content);
253
287
  const health = computeContextHealth(rows, boundaries.length);
254
288
  const sessionId = extractSessionId(content) ?? id;
289
+ const turns = parseAssistantTurns(content);
290
+ const drift = computeDrift(turns);
255
291
  // Load sub-agent JSONL files and attach as children to matching agent rows
256
292
  const subagentsDir = path.join(projectsDir, slug, id, 'subagents');
257
293
  let subagentsDirExists = false;
@@ -266,6 +302,9 @@ export function buildApiRouter(claudeHome) {
266
302
  // Build a map of tool_use_id → agentId from the parent session content
267
303
  const agentIdMap = extractAgentIds(content);
268
304
  for (const [toolUseId, agentId] of agentIdMap) {
305
+ // Validate agentId to prevent path traversal via crafted JSONL content
306
+ if (!/^[a-zA-Z0-9_-]+$/.test(agentId))
307
+ continue;
269
308
  const subAgentFile = path.join(subagentsDir, `agent-${agentId}.jsonl`);
270
309
  let subAgentContent;
271
310
  try {
@@ -291,13 +330,18 @@ export function buildApiRouter(claudeHome) {
291
330
  for (const row of rows) {
292
331
  if (row.type !== 'agent' || row.children.length === 0)
293
332
  continue;
294
- const childMax = Math.max(...row.children.map((c) => c.endTime ?? c.startTime));
333
+ let childMax = -Infinity;
334
+ for (const c of row.children) {
335
+ const end = c.endTime ?? c.startTime;
336
+ if (end > childMax)
337
+ childMax = end;
338
+ }
295
339
  if (childMax > (row.endTime ?? 0)) {
296
340
  row.endTime = childMax;
297
341
  row.duration = childMax - row.startTime;
298
342
  }
299
343
  }
300
- res.json({ rows, compactionBoundaries: boundaries, health, sessionId });
344
+ res.json({ rows, compactionBoundaries: boundaries, health, sessionId, drift });
301
345
  }
302
346
  catch (err) {
303
347
  const message = err instanceof Error ? err.message : String(err);
@@ -6,6 +6,7 @@ import chokidar from 'chokidar';
6
6
  import fs from 'node:fs';
7
7
  import { parseJsonlContent, parseCompactionBoundaries } from '../shared/parser';
8
8
  import { computeContextHealth } from '../shared/health';
9
+ import { parseAssistantTurns, computeDrift } from '../shared/drift';
9
10
  /**
10
11
  * Watch a single JSONL session file for appended content.
11
12
  * On each file change, reads only the newly appended bytes, parses them into
@@ -39,23 +40,31 @@ export function watchSession(filePath, callbacks) {
39
40
  finally {
40
41
  fs.closeSync(fd);
41
42
  }
42
- bytesRead = fileSize;
43
- const newContent = buffer.toString('utf8');
44
- const newRows = parseJsonlContent(newContent);
45
- if (newRows.length === 0)
43
+ // Only advance bytesRead for complete lines to avoid partial JSONL reads
44
+ const raw = buffer.toString('utf8');
45
+ const lastNewline = raw.lastIndexOf('\n');
46
+ if (lastNewline === -1) {
47
+ // No complete line yet — wait for next change event
46
48
  return;
47
- // Re-read full file for accurate health score (it's cumulative)
49
+ }
50
+ bytesRead += Buffer.byteLength(raw.slice(0, lastNewline + 1), 'utf8');
51
+ // Read full file once — needed for accurate health (cumulative metrics)
52
+ // and to produce complete rows with correct parent-child relationships
48
53
  let fullContent = '';
49
54
  try {
50
55
  fullContent = fs.readFileSync(filePath, 'utf8');
51
56
  }
52
57
  catch {
53
- fullContent = newContent;
58
+ fullContent = buffer.toString('utf8');
54
59
  }
55
- const boundaries = parseCompactionBoundaries(fullContent);
56
60
  const allRows = parseJsonlContent(fullContent);
61
+ if (allRows.length === 0)
62
+ return;
63
+ const boundaries = parseCompactionBoundaries(fullContent);
57
64
  const health = computeContextHealth(allRows, boundaries.length);
58
- callbacks.onNewRows(newRows, health, boundaries);
65
+ const turns = parseAssistantTurns(fullContent);
66
+ const drift = computeDrift(turns);
67
+ callbacks.onNewRows(allRows, health, boundaries, drift);
59
68
  }
60
69
  catch (err) {
61
70
  console.warn('[noctrace] watcher error:', err instanceof Error ? err.message : String(err));
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { WebSocketServer, WebSocket } from 'ws';
6
6
  import { spawn } from 'node:child_process';
7
+ import chokidar from 'chokidar';
7
8
  import path from 'node:path';
8
9
  import { watchSession } from './watcher';
9
10
  // ---------------------------------------------------------------------------
@@ -30,7 +31,38 @@ function send(ws, msg) {
30
31
  * back in real time using chokidar file watching.
31
32
  */
32
33
  export function setupWebSocket(server, claudeHome) {
33
- const wss = new WebSocketServer({ server, path: '/ws' });
34
+ const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 64 * 1024 });
35
+ // Watch the projects directory for new .jsonl session files.
36
+ // When a new file appears, broadcast to all connected clients so they
37
+ // can refresh their session list without a manual page reload.
38
+ const projectsBase = path.join(claudeHome, 'projects');
39
+ const dirWatcher = chokidar.watch(projectsBase, {
40
+ persistent: true,
41
+ ignoreInitial: true,
42
+ depth: 1,
43
+ });
44
+ dirWatcher.on('add', (filePath) => {
45
+ if (!filePath.endsWith('.jsonl'))
46
+ return;
47
+ // Derive the project slug from the parent directory name
48
+ const relative = path.relative(projectsBase, filePath);
49
+ const slug = path.dirname(relative);
50
+ if (!slug || slug === '.')
51
+ return;
52
+ const msg = { type: 'session-created', slug };
53
+ const payload = JSON.stringify(msg);
54
+ for (const client of wss.clients) {
55
+ if (client.readyState === WebSocket.OPEN) {
56
+ client.send(payload);
57
+ }
58
+ }
59
+ });
60
+ dirWatcher.on('error', (err) => {
61
+ console.warn('[noctrace] dir watcher error:', err instanceof Error ? err.message : String(err));
62
+ });
63
+ wss.on('close', () => {
64
+ dirWatcher.close().catch(() => { });
65
+ });
34
66
  wss.on('connection', (ws, _req) => {
35
67
  let stopWatcher = null;
36
68
  let resumeProc = null;
@@ -74,6 +106,11 @@ export function setupWebSocket(server, claudeHome) {
74
106
  send(ws, { type: 'resume-error', message: 'resume requires sessionId and message' });
75
107
  return;
76
108
  }
109
+ // Validate sessionId format — must be a UUID-like string, no dashes-starting args
110
+ if (!/^[a-zA-Z0-9_-]+$/.test(sessionId) || sessionId.startsWith('-')) {
111
+ send(ws, { type: 'resume-error', message: 'Invalid sessionId format' });
112
+ return;
113
+ }
77
114
  const args = ['--resume', sessionId, '--print', '--verbose', '--output-format', 'stream-json'];
78
115
  if (fork)
79
116
  args.push('--fork-session');
@@ -167,10 +204,16 @@ export function setupWebSocket(server, claudeHome) {
167
204
  }
168
205
  // Stop any existing watcher before starting a new one
169
206
  stopCurrent();
170
- const filePath = path.join(claudeHome, 'projects', slug, `${id}.jsonl`);
207
+ const projectsBase = path.join(claudeHome, 'projects');
208
+ const filePath = path.join(projectsBase, slug, `${id}.jsonl`);
209
+ const resolved = path.resolve(filePath);
210
+ if (!resolved.startsWith(path.resolve(projectsBase) + path.sep)) {
211
+ send(ws, { type: 'error', message: 'Invalid path' });
212
+ return;
213
+ }
171
214
  const handle = watchSession(filePath, {
172
- onNewRows: (rows, health, boundaries) => {
173
- send(ws, { type: 'rows', rows, health, boundaries });
215
+ onNewRows: (rows, health, boundaries, drift) => {
216
+ send(ws, { type: 'rows', rows, health, boundaries, drift });
174
217
  },
175
218
  });
176
219
  stopWatcher = handle.stop;
@@ -0,0 +1,94 @@
1
+ const SAMPLE_SIZE = 5;
2
+ /**
3
+ * Extract per-turn token usage from raw JSONL content.
4
+ *
5
+ * Scans for assistant records and sums all token usage fields per turn.
6
+ * Malformed lines and records missing usage data are silently skipped.
7
+ * Returns an array of {@link AssistantTurn} objects sorted by timestamp.
8
+ */
9
+ export function parseAssistantTurns(content) {
10
+ const turns = [];
11
+ for (const line of content.split('\n')) {
12
+ const trimmed = line.trim();
13
+ if (trimmed === '')
14
+ continue;
15
+ let record;
16
+ try {
17
+ record = JSON.parse(trimmed);
18
+ }
19
+ catch {
20
+ continue;
21
+ }
22
+ if (typeof record !== 'object' ||
23
+ record === null ||
24
+ record.type !== 'assistant') {
25
+ continue;
26
+ }
27
+ const raw = record;
28
+ const usage = raw.message?.usage;
29
+ if (usage == null)
30
+ continue;
31
+ const inputTokens = usage.input_tokens ?? 0;
32
+ const outputTokens = usage.output_tokens ?? 0;
33
+ const cacheCreationTokens = usage.cache_creation_input_tokens ?? 0;
34
+ const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
35
+ const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
36
+ if (totalTokens === 0 || isNaN(totalTokens))
37
+ continue;
38
+ const timestamp = raw.timestamp != null
39
+ ? new Date(raw.timestamp).getTime()
40
+ : 0;
41
+ turns.push({ timestamp, totalTokens, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens });
42
+ }
43
+ return turns.sort((a, b) => a.timestamp - b.timestamp);
44
+ }
45
+ /**
46
+ * Compute token drift factor from assistant turns.
47
+ *
48
+ * Compares the average total tokens of the first {@link SAMPLE_SIZE} turns
49
+ * (baseline) against the last {@link SAMPLE_SIZE} turns (current).
50
+ * Returns a {@link DriftAnalysis} with `driftFactor = 1.0` when there is
51
+ * insufficient data or when baseline is zero (guards against division by zero).
52
+ */
53
+ export function computeDrift(turns) {
54
+ const turnCount = turns.length;
55
+ const totalTokens = turns.reduce((sum, t) => sum + t.totalTokens, 0);
56
+ if (turnCount < SAMPLE_SIZE) {
57
+ return {
58
+ driftFactor: 1.0,
59
+ baselineTokens: 0,
60
+ currentTokens: 0,
61
+ turnCount,
62
+ totalTokens,
63
+ estimatedSavings: 0,
64
+ };
65
+ }
66
+ const firstSlice = turns.slice(0, SAMPLE_SIZE);
67
+ const lastSlice = turns.slice(-SAMPLE_SIZE);
68
+ const baselineTokens = Math.round(firstSlice.reduce((sum, t) => sum + t.totalTokens, 0) / SAMPLE_SIZE);
69
+ const currentTokens = Math.round(lastSlice.reduce((sum, t) => sum + t.totalTokens, 0) / SAMPLE_SIZE);
70
+ if (baselineTokens === 0) {
71
+ return {
72
+ driftFactor: 1.0,
73
+ baselineTokens,
74
+ currentTokens,
75
+ turnCount,
76
+ totalTokens,
77
+ estimatedSavings: 0,
78
+ };
79
+ }
80
+ const driftFactor = Math.round((currentTokens / baselineTokens) * 10) / 10;
81
+ let estimatedSavings = 0;
82
+ if (driftFactor > 2) {
83
+ // Tokens spent beyond a 2x drift threshold could be reclaimed by rotating the session
84
+ estimatedSavings = Math.max(0, totalTokens - turnCount * baselineTokens * 2);
85
+ }
86
+ return {
87
+ driftFactor,
88
+ baselineTokens,
89
+ currentTokens,
90
+ turnCount,
91
+ totalTokens,
92
+ estimatedSavings,
93
+ };
94
+ }
@@ -221,7 +221,7 @@ export function parseJsonlContent(content) {
221
221
  : (res ? res.endTime : null);
222
222
  const effectiveDuration = agentRealDuration !== null
223
223
  ? agentRealDuration
224
- : (res ? res.endTime - startTime : null);
224
+ : (res ? Math.max(0, res.endTime - startTime) : null);
225
225
  pending.push({
226
226
  id: block.id,
227
227
  toolName: block.name,
@@ -366,7 +366,12 @@ export function parseJsonlContent(content) {
366
366
  for (const row of rowById.values()) {
367
367
  if (row.type !== 'agent' || row.children.length === 0)
368
368
  continue;
369
- const childMax = Math.max(...row.children.map((c) => c.endTime ?? c.startTime));
369
+ let childMax = -Infinity;
370
+ for (const c of row.children) {
371
+ const end = c.endTime ?? c.startTime;
372
+ if (end > childMax)
373
+ childMax = end;
374
+ }
370
375
  if (childMax > (row.endTime ?? 0)) {
371
376
  row.endTime = childMax;
372
377
  row.duration = childMax - row.startTime;
@@ -501,7 +506,7 @@ export function parseSubAgentContent(content) {
501
506
  label: buildLabel(block.name, block.input),
502
507
  startTime,
503
508
  endTime: res ? res.endTime : null,
504
- duration: res ? res.endTime - startTime : null,
509
+ duration: res ? Math.max(0, res.endTime - startTime) : null,
505
510
  status: res ? (res.isError ? 'error' : 'success') : 'running',
506
511
  parentAgentId: null,
507
512
  input: block.input,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noctrace",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Chrome DevTools Network-tab-style waterfall visualizer for Claude Code agent workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",