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.
- package/README.md +18 -3
- package/dist/client/assets/{index-DKj34--U.css → index-B8NjnKEc.css} +1 -1
- package/dist/client/assets/{index-qeBZVwft.js → index-Cfpt-dCZ.js} +2 -2
- package/dist/client/index.html +2 -2
- package/dist/server/server/routes/api.js +47 -3
- package/dist/server/server/watcher.js +17 -8
- package/dist/server/server/ws.js +47 -4
- package/dist/server/shared/drift.js +94 -0
- package/dist/server/shared/parser.js +8 -3
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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));
|
package/dist/server/server/ws.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|