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.
- package/README.md +18 -3
- package/dist/client/assets/index-B8NjnKEc.css +2 -0
- package/dist/client/assets/index-CMLphI7n.js +10 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/routes/api.js +132 -13
- package/dist/server/server/watcher.js +17 -8
- package/dist/server/server/ws.js +123 -5
- package/dist/server/shared/drift.js +94 -0
- package/dist/server/shared/parser.js +32 -3
- package/package.json +1 -1
- package/dist/client/assets/index-F4UsTHKi.js +0 -9
- package/dist/client/assets/index-YEd_JbJ0.css +0 -2
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
201
|
+
parsed = JSON.parse(line);
|
|
122
202
|
}
|
|
123
203
|
catch {
|
|
124
|
-
|
|
204
|
+
continue;
|
|
125
205
|
}
|
|
126
|
-
|
|
127
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
/**
|