noctrace 0.1.1 → 0.3.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,6 +7,54 @@ 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';
11
+ /**
12
+ * Read ~/.claude/sessions/*.json and return a Set of sessionIds
13
+ * whose PID is still a running claude process.
14
+ * The registry sessionId matches the JSONL filename.
15
+ */
16
+ async function getRunningSessionIds(claudeHome) {
17
+ const sessionsDir = path.join(claudeHome, 'sessions');
18
+ const running = new Set();
19
+ let files;
20
+ try {
21
+ files = await fs.readdir(sessionsDir);
22
+ }
23
+ catch {
24
+ return running;
25
+ }
26
+ for (const file of files) {
27
+ if (!file.endsWith('.json'))
28
+ continue;
29
+ try {
30
+ const raw = await fs.readFile(path.join(sessionsDir, file), 'utf8');
31
+ const data = JSON.parse(raw);
32
+ const pid = typeof data['pid'] === 'number' ? data['pid'] : null;
33
+ const sid = typeof data['sessionId'] === 'string' ? data['sessionId'] : null;
34
+ if (pid !== null && sid) {
35
+ try {
36
+ process.kill(pid, 0);
37
+ running.add(sid);
38
+ }
39
+ catch {
40
+ // process not running
41
+ }
42
+ }
43
+ }
44
+ catch {
45
+ // skip malformed files
46
+ }
47
+ }
48
+ return running;
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
+ }
10
58
  /** Build the Express router, scoped to a given Claude home directory. */
11
59
  export function buildApiRouter(claudeHome) {
12
60
  const router = Router();
@@ -29,6 +77,7 @@ export function buildApiRouter(claudeHome) {
29
77
  res.json([]);
30
78
  return;
31
79
  }
80
+ const runningSessions = await getRunningSessionIds(claudeHome);
32
81
  const projects = [];
33
82
  for (const entry of entries) {
34
83
  const entryPath = path.join(projectsDir, entry);
@@ -63,10 +112,26 @@ export function buildApiRouter(claudeHome) {
63
112
  }
64
113
  }
65
114
  const decodedPath = entry.replace(/-/g, '/');
115
+ // Count sessions with a live process or recent file activity
116
+ let activeSessionCount = 0;
117
+ for (const jf of jsonlFiles) {
118
+ const sid = jf.replace(/\.jsonl$/, '');
119
+ if (runningSessions.has(sid)) {
120
+ activeSessionCount++;
121
+ continue;
122
+ }
123
+ try {
124
+ const jstat = await fs.stat(path.join(entryPath, jf));
125
+ if (Date.now() - jstat.mtime.getTime() < 120_000)
126
+ activeSessionCount++;
127
+ }
128
+ catch { /* skip */ }
129
+ }
66
130
  projects.push({
67
131
  slug: entry,
68
132
  path: decodedPath,
69
133
  sessionCount,
134
+ activeSessionCount,
70
135
  lastModified: latestMtime.toISOString(),
71
136
  });
72
137
  }
@@ -88,6 +153,13 @@ export function buildApiRouter(claudeHome) {
88
153
  router.get('/sessions/:slug', async (req, res) => {
89
154
  const { slug } = req.params;
90
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
+ }
91
163
  try {
92
164
  let files;
93
165
  try {
@@ -97,6 +169,7 @@ export function buildApiRouter(claudeHome) {
97
169
  res.status(404).json({ error: `Project not found: ${slug}` });
98
170
  return;
99
171
  }
172
+ const runningSessions = await getRunningSessionIds(claudeHome);
100
173
  const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
101
174
  const sessions = [];
102
175
  for (const file of jsonlFiles) {
@@ -111,31 +184,56 @@ export function buildApiRouter(claudeHome) {
111
184
  }
112
185
  let startTime = null;
113
186
  let rowCount = 0;
187
+ let permissionMode = null;
188
+ let isRemoteControlled = false;
189
+ let isActive = false;
114
190
  try {
115
191
  const content = await fs.readFile(filePath, 'utf8');
116
- // Extract startTime from the first non-empty line's timestamp field
117
- const firstLine = content.split('\n').find((l) => l.trim() !== '');
118
- if (firstLine) {
192
+ const lines = content.split('\n');
193
+ // Extract metadata from lines (scan first 50 for speed)
194
+ const scanLimit = Math.min(lines.length, 50);
195
+ for (let i = 0; i < scanLimit; i++) {
196
+ const line = lines[i].trim();
197
+ if (!line)
198
+ continue;
119
199
  let parsed;
120
200
  try {
121
- parsed = JSON.parse(firstLine);
201
+ parsed = JSON.parse(line);
122
202
  }
123
203
  catch {
124
- // skip
204
+ continue;
125
205
  }
126
- if (typeof parsed === 'object' &&
127
- parsed !== null &&
128
- !Array.isArray(parsed) &&
129
- 'timestamp' in parsed &&
130
- typeof parsed['timestamp'] === 'string') {
206
+ // Extract startTime from first record with timestamp
207
+ if (startTime === null && typeof parsed['timestamp'] === 'string') {
131
208
  startTime = parsed['timestamp'];
132
209
  }
210
+ // Extract permissionMode from user records
211
+ if (parsed['type'] === 'user' && 'permissionMode' in parsed && permissionMode === null) {
212
+ permissionMode = parsed['permissionMode'] ?? null;
213
+ }
214
+ // Detect remote control from bridge_status system records
215
+ if (parsed['type'] === 'system' && parsed['subtype'] === 'bridge_status') {
216
+ isRemoteControlled = true;
217
+ }
133
218
  }
134
- rowCount = parseJsonlContent(content).length;
219
+ rowCount = lines.filter((l) => l.trim()).length;
220
+ // Active if: live process in registry OR file modified within last 2 minutes
221
+ // Registry covers CLI sessions; mtime covers Desktop app sessions
222
+ isActive = runningSessions.has(id) || (Date.now() - stat.mtime.getTime() < 120_000);
135
223
  }
136
224
  catch {
137
225
  // Unreadable file — still include with null startTime
138
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
+ }
139
237
  sessions.push({
140
238
  id,
141
239
  projectSlug: slug,
@@ -143,6 +241,10 @@ export function buildApiRouter(claudeHome) {
143
241
  startTime,
144
242
  lastModified: stat.mtime.toISOString(),
145
243
  rowCount,
244
+ isActive,
245
+ permissionMode,
246
+ isRemoteControlled,
247
+ driftFactor,
146
248
  });
147
249
  }
148
250
  // Sort by lastModified descending (most recent first)
@@ -164,6 +266,13 @@ export function buildApiRouter(claudeHome) {
164
266
  router.get('/session/:slug/:id', async (req, res) => {
165
267
  const { slug, id } = req.params;
166
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
+ }
167
276
  try {
168
277
  let content;
169
278
  try {
@@ -177,6 +286,8 @@ export function buildApiRouter(claudeHome) {
177
286
  const boundaries = parseCompactionBoundaries(content);
178
287
  const health = computeContextHealth(rows, boundaries.length);
179
288
  const sessionId = extractSessionId(content) ?? id;
289
+ const turns = parseAssistantTurns(content);
290
+ const drift = computeDrift(turns);
180
291
  // Load sub-agent JSONL files and attach as children to matching agent rows
181
292
  const subagentsDir = path.join(projectsDir, slug, id, 'subagents');
182
293
  let subagentsDirExists = false;
@@ -191,6 +302,9 @@ export function buildApiRouter(claudeHome) {
191
302
  // Build a map of tool_use_id → agentId from the parent session content
192
303
  const agentIdMap = extractAgentIds(content);
193
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;
194
308
  const subAgentFile = path.join(subagentsDir, `agent-${agentId}.jsonl`);
195
309
  let subAgentContent;
196
310
  try {
@@ -216,13 +330,18 @@ export function buildApiRouter(claudeHome) {
216
330
  for (const row of rows) {
217
331
  if (row.type !== 'agent' || row.children.length === 0)
218
332
  continue;
219
- 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
+ }
220
339
  if (childMax > (row.endTime ?? 0)) {
221
340
  row.endTime = childMax;
222
341
  row.duration = childMax - row.startTime;
223
342
  }
224
343
  }
225
- res.json({ rows, compactionBoundaries: boundaries, health, sessionId });
344
+ res.json({ rows, compactionBoundaries: boundaries, health, sessionId, drift });
226
345
  }
227
346
  catch (err) {
228
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));
@@ -3,6 +3,7 @@
3
3
  * Mounts at /ws. One watcher per connection, cleaned up on disconnect.
4
4
  */
5
5
  import { WebSocketServer, WebSocket } from 'ws';
6
+ import { spawn } from 'node:child_process';
6
7
  import path from 'node:path';
7
8
  import { watchSession } from './watcher';
8
9
  // ---------------------------------------------------------------------------
@@ -12,7 +13,8 @@ function isClientMessage(val) {
12
13
  if (typeof val !== 'object' || val === null)
13
14
  return false;
14
15
  const obj = val;
15
- return obj['type'] === 'watch' || obj['type'] === 'unwatch';
16
+ return obj['type'] === 'watch' || obj['type'] === 'unwatch'
17
+ || obj['type'] === 'resume' || obj['type'] === 'resume-cancel';
16
18
  }
17
19
  function send(ws, msg) {
18
20
  if (ws.readyState === WebSocket.OPEN) {
@@ -28,15 +30,22 @@ function send(ws, msg) {
28
30
  * back in real time using chokidar file watching.
29
31
  */
30
32
  export function setupWebSocket(server, claudeHome) {
31
- const wss = new WebSocketServer({ server, path: '/ws' });
33
+ const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 64 * 1024 });
32
34
  wss.on('connection', (ws, _req) => {
33
35
  let stopWatcher = null;
36
+ let resumeProc = null;
34
37
  const stopCurrent = () => {
35
38
  if (stopWatcher) {
36
39
  stopWatcher();
37
40
  stopWatcher = null;
38
41
  }
39
42
  };
43
+ const killResume = () => {
44
+ if (resumeProc) {
45
+ resumeProc.kill('SIGTERM');
46
+ resumeProc = null;
47
+ }
48
+ };
40
49
  ws.on('message', (data) => {
41
50
  let parsed;
42
51
  try {
@@ -54,6 +63,107 @@ export function setupWebSocket(server, claudeHome) {
54
63
  stopCurrent();
55
64
  return;
56
65
  }
66
+ if (parsed.type === 'resume-cancel') {
67
+ killResume();
68
+ return;
69
+ }
70
+ if (parsed.type === 'resume') {
71
+ killResume();
72
+ const { sessionId, message: userMsg, fork } = parsed;
73
+ if (!sessionId || !userMsg) {
74
+ send(ws, { type: 'resume-error', message: 'resume requires sessionId and message' });
75
+ return;
76
+ }
77
+ // Validate sessionId format — must be a UUID-like string, no dashes-starting args
78
+ if (!/^[a-zA-Z0-9_-]+$/.test(sessionId) || sessionId.startsWith('-')) {
79
+ send(ws, { type: 'resume-error', message: 'Invalid sessionId format' });
80
+ return;
81
+ }
82
+ const args = ['--resume', sessionId, '--print', '--verbose', '--output-format', 'stream-json'];
83
+ if (fork)
84
+ args.push('--fork-session');
85
+ args.push(userMsg);
86
+ try {
87
+ const proc = spawn('claude', args, {
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ env: { ...process.env },
90
+ });
91
+ resumeProc = proc;
92
+ // Buffer for incomplete lines from chunked TCP data
93
+ let lineBuffer = '';
94
+ /**
95
+ * Process a complete, newline-terminated stream-json line.
96
+ * Extracts assistant text chunks and ignores result-type messages
97
+ * (the final result is already accumulated via chunk messages).
98
+ */
99
+ const processLine = (line) => {
100
+ if (!line.trim())
101
+ return;
102
+ try {
103
+ const obj = JSON.parse(line);
104
+ if (obj['type'] === 'assistant') {
105
+ // Extract text from message content blocks
106
+ const msgContent = obj['message'];
107
+ if (typeof msgContent === 'object' && msgContent !== null) {
108
+ const content = msgContent['content'];
109
+ if (Array.isArray(content)) {
110
+ for (const block of content) {
111
+ if (typeof block === 'object' && block !== null &&
112
+ block['type'] === 'text' &&
113
+ typeof block['text'] === 'string') {
114
+ send(ws, { type: 'resume-chunk', text: block['text'] });
115
+ }
116
+ }
117
+ }
118
+ }
119
+ else if (typeof msgContent === 'string') {
120
+ send(ws, { type: 'resume-chunk', text: msgContent });
121
+ }
122
+ }
123
+ // 'result' type: final accumulated text — no additional chunk needed
124
+ // since assistant chunks have already been streamed incrementally
125
+ }
126
+ catch {
127
+ // Non-JSON line (e.g. debug output) — ignore silently
128
+ }
129
+ };
130
+ proc.stdout?.on('data', (chunk) => {
131
+ lineBuffer += chunk.toString();
132
+ const lines = lineBuffer.split('\n');
133
+ // All but the last element are complete lines; last may be partial
134
+ lineBuffer = lines.pop() ?? '';
135
+ for (const line of lines) {
136
+ processLine(line);
137
+ }
138
+ });
139
+ proc.stdout?.on('end', () => {
140
+ // Flush any remaining buffered content
141
+ if (lineBuffer.trim()) {
142
+ processLine(lineBuffer);
143
+ lineBuffer = '';
144
+ }
145
+ });
146
+ proc.stderr?.on('data', (_chunk) => {
147
+ // Intentionally suppress stderr — claude CLI writes progress to stderr
148
+ // which would pollute the chat output with non-content noise
149
+ });
150
+ proc.on('close', (code) => {
151
+ send(ws, { type: 'resume-done', exitCode: code });
152
+ if (resumeProc === proc)
153
+ resumeProc = null;
154
+ });
155
+ proc.on('error', (err) => {
156
+ send(ws, { type: 'resume-error', message: err.message });
157
+ if (resumeProc === proc)
158
+ resumeProc = null;
159
+ });
160
+ }
161
+ catch (err) {
162
+ const msg = err instanceof Error ? err.message : String(err);
163
+ send(ws, { type: 'resume-error', message: msg });
164
+ }
165
+ return;
166
+ }
57
167
  // Watch message
58
168
  const { slug, id } = parsed;
59
169
  if (!slug || !id) {
@@ -62,20 +172,28 @@ export function setupWebSocket(server, claudeHome) {
62
172
  }
63
173
  // Stop any existing watcher before starting a new one
64
174
  stopCurrent();
65
- const filePath = path.join(claudeHome, 'projects', slug, `${id}.jsonl`);
175
+ const projectsBase = path.join(claudeHome, 'projects');
176
+ const filePath = path.join(projectsBase, slug, `${id}.jsonl`);
177
+ const resolved = path.resolve(filePath);
178
+ if (!resolved.startsWith(path.resolve(projectsBase) + path.sep)) {
179
+ send(ws, { type: 'error', message: 'Invalid path' });
180
+ return;
181
+ }
66
182
  const handle = watchSession(filePath, {
67
- onNewRows: (rows, health, boundaries) => {
68
- send(ws, { type: 'rows', rows, health, boundaries });
183
+ onNewRows: (rows, health, boundaries, drift) => {
184
+ send(ws, { type: 'rows', rows, health, boundaries, drift });
69
185
  },
70
186
  });
71
187
  stopWatcher = handle.stop;
72
188
  });
73
189
  ws.on('close', () => {
74
190
  stopCurrent();
191
+ killResume();
75
192
  });
76
193
  ws.on('error', (err) => {
77
194
  console.warn('[noctrace] ws error:', err.message);
78
195
  stopCurrent();
196
+ killResume();
79
197
  });
80
198
  });
81
199
  }
@@ -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,
@@ -289,6 +289,7 @@ export function parseJsonlContent(content) {
289
289
  output: p.output,
290
290
  inputTokens: p.inputTokens,
291
291
  outputTokens: p.outputTokens,
292
+ tokenDelta: 0,
292
293
  contextFillPercent: p.contextFillPercent,
293
294
  isReread: p.isReread,
294
295
  children: [],
@@ -321,6 +322,7 @@ export function parseJsonlContent(content) {
321
322
  output: res ? res.output : null,
322
323
  inputTokens: sp.inputTokens,
323
324
  outputTokens: sp.outputTokens,
325
+ tokenDelta: 0,
324
326
  contextFillPercent: ctxFill,
325
327
  isReread,
326
328
  children: [],
@@ -364,7 +366,12 @@ export function parseJsonlContent(content) {
364
366
  for (const row of rowById.values()) {
365
367
  if (row.type !== 'agent' || row.children.length === 0)
366
368
  continue;
367
- 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
+ }
368
375
  if (childMax > (row.endTime ?? 0)) {
369
376
  row.endTime = childMax;
370
377
  row.duration = childMax - row.startTime;
@@ -382,6 +389,19 @@ export function parseJsonlContent(content) {
382
389
  for (const row of rowById.values()) {
383
390
  row.contextFillPercent = (row.inputTokens / effectiveWindow) * 100;
384
391
  }
392
+ // Compute per-row token delta from consecutive inputTokens (sorted by startTime)
393
+ function computeDeltas(rows) {
394
+ const sorted = [...rows].sort((a, b) => a.startTime - b.startTime);
395
+ let prev = 0;
396
+ for (const row of sorted) {
397
+ row.tokenDelta = row.inputTokens > 0 ? Math.max(0, row.inputTokens - prev) : 0;
398
+ if (row.inputTokens > 0)
399
+ prev = row.inputTokens;
400
+ if (row.children.length > 0)
401
+ computeDeltas(row.children);
402
+ }
403
+ }
404
+ computeDeltas(top);
385
405
  return top;
386
406
  }
387
407
  /**
@@ -486,19 +506,28 @@ export function parseSubAgentContent(content) {
486
506
  label: buildLabel(block.name, block.input),
487
507
  startTime,
488
508
  endTime: res ? res.endTime : null,
489
- duration: res ? res.endTime - startTime : null,
509
+ duration: res ? Math.max(0, res.endTime - startTime) : null,
490
510
  status: res ? (res.isError ? 'error' : 'success') : 'running',
491
511
  parentAgentId: null,
492
512
  input: block.input,
493
513
  output: res ? res.output : null,
494
514
  inputTokens,
495
515
  outputTokens,
516
+ tokenDelta: 0,
496
517
  contextFillPercent,
497
518
  isReread,
498
519
  children: [],
499
520
  });
500
521
  }
501
522
  }
523
+ // Compute per-row token delta for sub-agent rows
524
+ const sorted = [...rows].sort((a, b) => a.startTime - b.startTime);
525
+ let prevInput = 0;
526
+ for (const row of sorted) {
527
+ row.tokenDelta = row.inputTokens > 0 ? Math.max(0, row.inputTokens - prevInput) : 0;
528
+ if (row.inputTokens > 0)
529
+ prevInput = row.inputTokens;
530
+ }
502
531
  return rows;
503
532
  }
504
533
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noctrace",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Chrome DevTools Network-tab-style waterfall visualizer for Claude Code agent workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",