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.
@@ -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
- // Extract startTime from the first non-empty line's timestamp field
117
- const firstLine = content.split('\n').find((l) => l.trim() !== '');
118
- if (firstLine) {
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(firstLine);
185
+ parsed = JSON.parse(line);
122
186
  }
123
187
  catch {
124
- // skip
188
+ continue;
125
189
  }
126
- if (typeof parsed === 'object' &&
127
- parsed !== null &&
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)
@@ -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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noctrace",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Chrome DevTools Network-tab-style waterfall visualizer for Claude Code agent workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",