noctrace 0.1.0 → 0.2.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/dist/client/assets/index-DKj34--U.css +2 -0
- package/dist/client/assets/index-qeBZVwft.js +10 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/routes/api.js +85 -10
- package/dist/server/server/ws.js +108 -1
- package/dist/server/shared/parser.js +24 -0
- package/package.json +1 -1
- package/dist/client/assets/index-CNr7mTkl.js +0 -9
- package/dist/client/assets/index-YEd_JbJ0.css +0 -2
|
@@ -7,6 +7,45 @@ 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
|
+
/**
|
|
11
|
+
* Read ~/.claude/sessions/*.json and return a Set of sessionIds
|
|
12
|
+
* whose PID is still a running claude process.
|
|
13
|
+
* The registry sessionId matches the JSONL filename.
|
|
14
|
+
*/
|
|
15
|
+
async function getRunningSessionIds(claudeHome) {
|
|
16
|
+
const sessionsDir = path.join(claudeHome, 'sessions');
|
|
17
|
+
const running = new Set();
|
|
18
|
+
let files;
|
|
19
|
+
try {
|
|
20
|
+
files = await fs.readdir(sessionsDir);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return running;
|
|
24
|
+
}
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
if (!file.endsWith('.json'))
|
|
27
|
+
continue;
|
|
28
|
+
try {
|
|
29
|
+
const raw = await fs.readFile(path.join(sessionsDir, file), 'utf8');
|
|
30
|
+
const data = JSON.parse(raw);
|
|
31
|
+
const pid = typeof data['pid'] === 'number' ? data['pid'] : null;
|
|
32
|
+
const sid = typeof data['sessionId'] === 'string' ? data['sessionId'] : null;
|
|
33
|
+
if (pid !== null && sid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
running.add(sid);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// process not running
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// skip malformed files
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return running;
|
|
48
|
+
}
|
|
10
49
|
/** Build the Express router, scoped to a given Claude home directory. */
|
|
11
50
|
export function buildApiRouter(claudeHome) {
|
|
12
51
|
const router = Router();
|
|
@@ -29,6 +68,7 @@ export function buildApiRouter(claudeHome) {
|
|
|
29
68
|
res.json([]);
|
|
30
69
|
return;
|
|
31
70
|
}
|
|
71
|
+
const runningSessions = await getRunningSessionIds(claudeHome);
|
|
32
72
|
const projects = [];
|
|
33
73
|
for (const entry of entries) {
|
|
34
74
|
const entryPath = path.join(projectsDir, entry);
|
|
@@ -63,10 +103,26 @@ export function buildApiRouter(claudeHome) {
|
|
|
63
103
|
}
|
|
64
104
|
}
|
|
65
105
|
const decodedPath = entry.replace(/-/g, '/');
|
|
106
|
+
// Count sessions with a live process or recent file activity
|
|
107
|
+
let activeSessionCount = 0;
|
|
108
|
+
for (const jf of jsonlFiles) {
|
|
109
|
+
const sid = jf.replace(/\.jsonl$/, '');
|
|
110
|
+
if (runningSessions.has(sid)) {
|
|
111
|
+
activeSessionCount++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const jstat = await fs.stat(path.join(entryPath, jf));
|
|
116
|
+
if (Date.now() - jstat.mtime.getTime() < 120_000)
|
|
117
|
+
activeSessionCount++;
|
|
118
|
+
}
|
|
119
|
+
catch { /* skip */ }
|
|
120
|
+
}
|
|
66
121
|
projects.push({
|
|
67
122
|
slug: entry,
|
|
68
123
|
path: decodedPath,
|
|
69
124
|
sessionCount,
|
|
125
|
+
activeSessionCount,
|
|
70
126
|
lastModified: latestMtime.toISOString(),
|
|
71
127
|
});
|
|
72
128
|
}
|
|
@@ -97,6 +153,7 @@ export function buildApiRouter(claudeHome) {
|
|
|
97
153
|
res.status(404).json({ error: `Project not found: ${slug}` });
|
|
98
154
|
return;
|
|
99
155
|
}
|
|
156
|
+
const runningSessions = await getRunningSessionIds(claudeHome);
|
|
100
157
|
const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
|
|
101
158
|
const sessions = [];
|
|
102
159
|
for (const file of jsonlFiles) {
|
|
@@ -111,27 +168,42 @@ export function buildApiRouter(claudeHome) {
|
|
|
111
168
|
}
|
|
112
169
|
let startTime = null;
|
|
113
170
|
let rowCount = 0;
|
|
171
|
+
let permissionMode = null;
|
|
172
|
+
let isRemoteControlled = false;
|
|
173
|
+
let isActive = false;
|
|
114
174
|
try {
|
|
115
175
|
const content = await fs.readFile(filePath, 'utf8');
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
176
|
+
const lines = content.split('\n');
|
|
177
|
+
// Extract metadata from lines (scan first 50 for speed)
|
|
178
|
+
const scanLimit = Math.min(lines.length, 50);
|
|
179
|
+
for (let i = 0; i < scanLimit; i++) {
|
|
180
|
+
const line = lines[i].trim();
|
|
181
|
+
if (!line)
|
|
182
|
+
continue;
|
|
119
183
|
let parsed;
|
|
120
184
|
try {
|
|
121
|
-
parsed = JSON.parse(
|
|
185
|
+
parsed = JSON.parse(line);
|
|
122
186
|
}
|
|
123
187
|
catch {
|
|
124
|
-
|
|
188
|
+
continue;
|
|
125
189
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
!Array.isArray(parsed) &&
|
|
129
|
-
'timestamp' in parsed &&
|
|
130
|
-
typeof parsed['timestamp'] === 'string') {
|
|
190
|
+
// Extract startTime from first record with timestamp
|
|
191
|
+
if (startTime === null && typeof parsed['timestamp'] === 'string') {
|
|
131
192
|
startTime = parsed['timestamp'];
|
|
132
193
|
}
|
|
194
|
+
// Extract permissionMode from user records
|
|
195
|
+
if (parsed['type'] === 'user' && 'permissionMode' in parsed && permissionMode === null) {
|
|
196
|
+
permissionMode = parsed['permissionMode'] ?? null;
|
|
197
|
+
}
|
|
198
|
+
// Detect remote control from bridge_status system records
|
|
199
|
+
if (parsed['type'] === 'system' && parsed['subtype'] === 'bridge_status') {
|
|
200
|
+
isRemoteControlled = true;
|
|
201
|
+
}
|
|
133
202
|
}
|
|
134
203
|
rowCount = parseJsonlContent(content).length;
|
|
204
|
+
// Active if: live process in registry OR file modified within last 2 minutes
|
|
205
|
+
// Registry covers CLI sessions; mtime covers Desktop app sessions
|
|
206
|
+
isActive = runningSessions.has(id) || (Date.now() - stat.mtime.getTime() < 120_000);
|
|
135
207
|
}
|
|
136
208
|
catch {
|
|
137
209
|
// Unreadable file — still include with null startTime
|
|
@@ -143,6 +215,9 @@ export function buildApiRouter(claudeHome) {
|
|
|
143
215
|
startTime,
|
|
144
216
|
lastModified: stat.mtime.toISOString(),
|
|
145
217
|
rowCount,
|
|
218
|
+
isActive,
|
|
219
|
+
permissionMode,
|
|
220
|
+
isRemoteControlled,
|
|
146
221
|
});
|
|
147
222
|
}
|
|
148
223
|
// Sort by lastModified descending (most recent first)
|
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) {
|
|
@@ -31,12 +33,19 @@ export function setupWebSocket(server, claudeHome) {
|
|
|
31
33
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
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,102 @@ 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
|
+
const args = ['--resume', sessionId, '--print', '--verbose', '--output-format', 'stream-json'];
|
|
78
|
+
if (fork)
|
|
79
|
+
args.push('--fork-session');
|
|
80
|
+
args.push(userMsg);
|
|
81
|
+
try {
|
|
82
|
+
const proc = spawn('claude', args, {
|
|
83
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
84
|
+
env: { ...process.env },
|
|
85
|
+
});
|
|
86
|
+
resumeProc = proc;
|
|
87
|
+
// Buffer for incomplete lines from chunked TCP data
|
|
88
|
+
let lineBuffer = '';
|
|
89
|
+
/**
|
|
90
|
+
* Process a complete, newline-terminated stream-json line.
|
|
91
|
+
* Extracts assistant text chunks and ignores result-type messages
|
|
92
|
+
* (the final result is already accumulated via chunk messages).
|
|
93
|
+
*/
|
|
94
|
+
const processLine = (line) => {
|
|
95
|
+
if (!line.trim())
|
|
96
|
+
return;
|
|
97
|
+
try {
|
|
98
|
+
const obj = JSON.parse(line);
|
|
99
|
+
if (obj['type'] === 'assistant') {
|
|
100
|
+
// Extract text from message content blocks
|
|
101
|
+
const msgContent = obj['message'];
|
|
102
|
+
if (typeof msgContent === 'object' && msgContent !== null) {
|
|
103
|
+
const content = msgContent['content'];
|
|
104
|
+
if (Array.isArray(content)) {
|
|
105
|
+
for (const block of content) {
|
|
106
|
+
if (typeof block === 'object' && block !== null &&
|
|
107
|
+
block['type'] === 'text' &&
|
|
108
|
+
typeof block['text'] === 'string') {
|
|
109
|
+
send(ws, { type: 'resume-chunk', text: block['text'] });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (typeof msgContent === 'string') {
|
|
115
|
+
send(ws, { type: 'resume-chunk', text: msgContent });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// 'result' type: final accumulated text — no additional chunk needed
|
|
119
|
+
// since assistant chunks have already been streamed incrementally
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Non-JSON line (e.g. debug output) — ignore silently
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
proc.stdout?.on('data', (chunk) => {
|
|
126
|
+
lineBuffer += chunk.toString();
|
|
127
|
+
const lines = lineBuffer.split('\n');
|
|
128
|
+
// All but the last element are complete lines; last may be partial
|
|
129
|
+
lineBuffer = lines.pop() ?? '';
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
processLine(line);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
proc.stdout?.on('end', () => {
|
|
135
|
+
// Flush any remaining buffered content
|
|
136
|
+
if (lineBuffer.trim()) {
|
|
137
|
+
processLine(lineBuffer);
|
|
138
|
+
lineBuffer = '';
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
proc.stderr?.on('data', (_chunk) => {
|
|
142
|
+
// Intentionally suppress stderr — claude CLI writes progress to stderr
|
|
143
|
+
// which would pollute the chat output with non-content noise
|
|
144
|
+
});
|
|
145
|
+
proc.on('close', (code) => {
|
|
146
|
+
send(ws, { type: 'resume-done', exitCode: code });
|
|
147
|
+
if (resumeProc === proc)
|
|
148
|
+
resumeProc = null;
|
|
149
|
+
});
|
|
150
|
+
proc.on('error', (err) => {
|
|
151
|
+
send(ws, { type: 'resume-error', message: err.message });
|
|
152
|
+
if (resumeProc === proc)
|
|
153
|
+
resumeProc = null;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
158
|
+
send(ws, { type: 'resume-error', message: msg });
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
57
162
|
// Watch message
|
|
58
163
|
const { slug, id } = parsed;
|
|
59
164
|
if (!slug || !id) {
|
|
@@ -72,10 +177,12 @@ export function setupWebSocket(server, claudeHome) {
|
|
|
72
177
|
});
|
|
73
178
|
ws.on('close', () => {
|
|
74
179
|
stopCurrent();
|
|
180
|
+
killResume();
|
|
75
181
|
});
|
|
76
182
|
ws.on('error', (err) => {
|
|
77
183
|
console.warn('[noctrace] ws error:', err.message);
|
|
78
184
|
stopCurrent();
|
|
185
|
+
killResume();
|
|
79
186
|
});
|
|
80
187
|
});
|
|
81
188
|
}
|
|
@@ -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: [],
|
|
@@ -382,6 +384,19 @@ export function parseJsonlContent(content) {
|
|
|
382
384
|
for (const row of rowById.values()) {
|
|
383
385
|
row.contextFillPercent = (row.inputTokens / effectiveWindow) * 100;
|
|
384
386
|
}
|
|
387
|
+
// Compute per-row token delta from consecutive inputTokens (sorted by startTime)
|
|
388
|
+
function computeDeltas(rows) {
|
|
389
|
+
const sorted = [...rows].sort((a, b) => a.startTime - b.startTime);
|
|
390
|
+
let prev = 0;
|
|
391
|
+
for (const row of sorted) {
|
|
392
|
+
row.tokenDelta = row.inputTokens > 0 ? Math.max(0, row.inputTokens - prev) : 0;
|
|
393
|
+
if (row.inputTokens > 0)
|
|
394
|
+
prev = row.inputTokens;
|
|
395
|
+
if (row.children.length > 0)
|
|
396
|
+
computeDeltas(row.children);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
computeDeltas(top);
|
|
385
400
|
return top;
|
|
386
401
|
}
|
|
387
402
|
/**
|
|
@@ -493,12 +508,21 @@ export function parseSubAgentContent(content) {
|
|
|
493
508
|
output: res ? res.output : null,
|
|
494
509
|
inputTokens,
|
|
495
510
|
outputTokens,
|
|
511
|
+
tokenDelta: 0,
|
|
496
512
|
contextFillPercent,
|
|
497
513
|
isReread,
|
|
498
514
|
children: [],
|
|
499
515
|
});
|
|
500
516
|
}
|
|
501
517
|
}
|
|
518
|
+
// Compute per-row token delta for sub-agent rows
|
|
519
|
+
const sorted = [...rows].sort((a, b) => a.startTime - b.startTime);
|
|
520
|
+
let prevInput = 0;
|
|
521
|
+
for (const row of sorted) {
|
|
522
|
+
row.tokenDelta = row.inputTokens > 0 ? Math.max(0, row.inputTokens - prevInput) : 0;
|
|
523
|
+
if (row.inputTokens > 0)
|
|
524
|
+
prevInput = row.inputTokens;
|
|
525
|
+
}
|
|
502
526
|
return rows;
|
|
503
527
|
}
|
|
504
528
|
/**
|