noctrace 0.6.0 → 0.7.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 +15 -0
- package/bin/claude-config.js +139 -0
- package/bin/noctrace-mcp.js +249 -31
- package/bin/noctrace.js +31 -0
- package/bin/postinstall.js +52 -0
- package/bin/preuninstall.js +32 -0
- package/dist/client/assets/index-BWO5fecq.css +2 -0
- package/dist/client/assets/index-BhUwV-5i.js +30 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/routes/api.js +190 -2
- package/dist/server/shared/filter.js +1 -1
- package/dist/server/shared/parser.js +249 -10
- package/dist/server/shared/token-cost.js +68 -0
- package/package.json +4 -2
- package/dist/client/assets/index-C4vi082v.css +0 -2
- package/dist/client/assets/index-DqY0cF0g.js +0 -30
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-BhUwV-5i.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BWO5fecq.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="root"></div>
|
|
@@ -6,7 +6,7 @@ import { Router } from 'express';
|
|
|
6
6
|
import fs from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { WebSocket } from 'ws';
|
|
9
|
-
import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, } from '../../shared/parser.js';
|
|
9
|
+
import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, parseInstructionsLoaded, } from '../../shared/parser.js';
|
|
10
10
|
import { computeContextHealth } from '../../shared/health.js';
|
|
11
11
|
import { parseAssistantTurns, computeDrift } from '../../shared/drift.js';
|
|
12
12
|
import { attachEfficiencyTips } from '../../shared/tips.js';
|
|
@@ -66,6 +66,23 @@ function assertWithinBase(resolved, base) {
|
|
|
66
66
|
export function buildApiRouter(claudeHome, wss) {
|
|
67
67
|
const router = Router();
|
|
68
68
|
const projectsDir = path.join(claudeHome, 'projects');
|
|
69
|
+
const teamsDir = path.join(claudeHome, 'teams');
|
|
70
|
+
const tasksDir = path.join(claudeHome, 'tasks');
|
|
71
|
+
/**
|
|
72
|
+
* In-memory registry of MCP-registered session paths.
|
|
73
|
+
* Populated by POST /api/sessions/register; cleared on unregister or server restart.
|
|
74
|
+
* When non-empty the client operates in "MCP mode" and shows only these sessions.
|
|
75
|
+
*/
|
|
76
|
+
const registeredSessionPaths = new Set();
|
|
77
|
+
/** Broadcast a message to all connected WebSocket clients. */
|
|
78
|
+
function broadcast(msg) {
|
|
79
|
+
const payload = JSON.stringify(msg);
|
|
80
|
+
for (const client of wss.clients) {
|
|
81
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
82
|
+
client.send(payload);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
69
86
|
// ---------------------------------------------------------------------------
|
|
70
87
|
// GET /api/projects
|
|
71
88
|
// ---------------------------------------------------------------------------
|
|
@@ -152,6 +169,79 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
152
169
|
}
|
|
153
170
|
});
|
|
154
171
|
// ---------------------------------------------------------------------------
|
|
172
|
+
// GET /api/teams
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Scan ~/.claude/teams/ and return an array of AgentTeam objects.
|
|
176
|
+
* Returns an empty array if the teams directory doesn't exist.
|
|
177
|
+
* Each team includes its members (from config.json) and taskCount.
|
|
178
|
+
*/
|
|
179
|
+
router.get('/teams', async (_req, res) => {
|
|
180
|
+
try {
|
|
181
|
+
let teamDirs;
|
|
182
|
+
try {
|
|
183
|
+
teamDirs = await fs.readdir(teamsDir);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Teams directory doesn't exist — this is normal when the feature is unused
|
|
187
|
+
res.json([]);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const teams = [];
|
|
191
|
+
for (const teamName of teamDirs) {
|
|
192
|
+
const teamPath = path.join(teamsDir, teamName);
|
|
193
|
+
let stat;
|
|
194
|
+
try {
|
|
195
|
+
stat = await fs.stat(teamPath);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!stat.isDirectory())
|
|
201
|
+
continue;
|
|
202
|
+
// Read team config.json
|
|
203
|
+
const configPath = path.join(teamPath, 'config.json');
|
|
204
|
+
let members = [];
|
|
205
|
+
try {
|
|
206
|
+
const configRaw = await fs.readFile(configPath, 'utf8');
|
|
207
|
+
const config = JSON.parse(configRaw);
|
|
208
|
+
const rawMembers = Array.isArray(config['members']) ? config['members'] : [];
|
|
209
|
+
for (const m of rawMembers) {
|
|
210
|
+
if (typeof m !== 'object' || m === null)
|
|
211
|
+
continue;
|
|
212
|
+
const member = m;
|
|
213
|
+
members.push({
|
|
214
|
+
name: typeof member['name'] === 'string' ? member['name'] : 'Unknown',
|
|
215
|
+
agentId: typeof member['agent_id'] === 'string' ? member['agent_id']
|
|
216
|
+
: typeof member['agentId'] === 'string' ? member['agentId'] : '',
|
|
217
|
+
agentType: typeof member['agent_type'] === 'string' ? member['agent_type']
|
|
218
|
+
: typeof member['agentType'] === 'string' ? member['agentType'] : '',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// config.json missing or malformed — include team with empty members
|
|
224
|
+
}
|
|
225
|
+
// Count task files in ~/.claude/tasks/{team-name}/
|
|
226
|
+
let taskCount = 0;
|
|
227
|
+
const teamTasksDir = path.join(tasksDir, teamName);
|
|
228
|
+
try {
|
|
229
|
+
const taskFiles = await fs.readdir(teamTasksDir);
|
|
230
|
+
taskCount = taskFiles.length;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Task directory doesn't exist — taskCount stays 0
|
|
234
|
+
}
|
|
235
|
+
teams.push({ name: teamName, members, taskCount });
|
|
236
|
+
}
|
|
237
|
+
res.json(teams);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
241
|
+
res.status(500).json({ error: message });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
155
245
|
// GET /api/sessions/:slug
|
|
156
246
|
// ---------------------------------------------------------------------------
|
|
157
247
|
/**
|
|
@@ -300,6 +390,7 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
300
390
|
const sessionId = extractSessionId(content) ?? id;
|
|
301
391
|
const turns = parseAssistantTurns(content);
|
|
302
392
|
const drift = computeDrift(turns);
|
|
393
|
+
const instructionsLoaded = parseInstructionsLoaded(content);
|
|
303
394
|
// Load sub-agent JSONL files and attach as children to matching agent rows
|
|
304
395
|
const subagentsDir = path.join(projectsDir, slug, id, 'subagents');
|
|
305
396
|
let subagentsDirExists = false;
|
|
@@ -362,7 +453,7 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
362
453
|
return r.reduce((sum, row) => sum + row.tips.length + countTips(row.children), 0);
|
|
363
454
|
}
|
|
364
455
|
const tipCount = countTips(rows);
|
|
365
|
-
res.json({ rows, compactionBoundaries: boundaries, health, sessionId, drift, tipCount });
|
|
456
|
+
res.json({ rows, compactionBoundaries: boundaries, health, sessionId, drift, tipCount, instructionsLoaded });
|
|
366
457
|
}
|
|
367
458
|
catch (err) {
|
|
368
459
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -409,5 +500,102 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
409
500
|
res.status(500).json({ error: message });
|
|
410
501
|
}
|
|
411
502
|
});
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// POST /api/sessions/register
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
/**
|
|
507
|
+
* Register an MCP-managed session path so noctrace can display it.
|
|
508
|
+
* Body: { sessionPath: string } — absolute path to a .jsonl file.
|
|
509
|
+
* Broadcasts `session-registered` to all WebSocket clients.
|
|
510
|
+
*/
|
|
511
|
+
router.post('/sessions/register', async (req, res) => {
|
|
512
|
+
try {
|
|
513
|
+
const body = req.body;
|
|
514
|
+
const sessionPath = typeof body['sessionPath'] === 'string' ? body['sessionPath'] : null;
|
|
515
|
+
if (!sessionPath) {
|
|
516
|
+
res.status(400).json({ error: 'sessionPath is required' });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (!sessionPath.endsWith('.jsonl')) {
|
|
520
|
+
res.status(400).json({ error: 'sessionPath must be a .jsonl file' });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
// Normalize and validate that the path is within the Claude projects directory
|
|
524
|
+
const resolvedPath = path.resolve(sessionPath);
|
|
525
|
+
try {
|
|
526
|
+
assertWithinBase(resolvedPath, projectsDir);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
res.status(400).json({ error: 'sessionPath must be within the Claude projects directory' });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// Verify the file exists (best-effort — it may appear shortly after the MCP starts)
|
|
533
|
+
try {
|
|
534
|
+
await fs.access(resolvedPath);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
// File not yet created — register anyway; the watcher will pick it up
|
|
538
|
+
}
|
|
539
|
+
registeredSessionPaths.add(resolvedPath);
|
|
540
|
+
broadcast({ type: 'session-registered', sessionPath: resolvedPath });
|
|
541
|
+
res.json({ ok: true });
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
545
|
+
res.status(500).json({ error: message });
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// POST /api/sessions/unregister
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
/**
|
|
552
|
+
* Remove a previously registered MCP session path from the registry.
|
|
553
|
+
* Body: { sessionPath: string }.
|
|
554
|
+
* Broadcasts `session-unregistered` to all WebSocket clients.
|
|
555
|
+
*/
|
|
556
|
+
router.post('/sessions/unregister', (req, res) => {
|
|
557
|
+
try {
|
|
558
|
+
const body = req.body;
|
|
559
|
+
const sessionPath = typeof body['sessionPath'] === 'string' ? body['sessionPath'] : null;
|
|
560
|
+
if (!sessionPath) {
|
|
561
|
+
res.status(400).json({ error: 'sessionPath is required' });
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const resolvedPath = path.resolve(sessionPath);
|
|
565
|
+
registeredSessionPaths.delete(resolvedPath);
|
|
566
|
+
broadcast({ type: 'session-unregistered', sessionPath: resolvedPath });
|
|
567
|
+
res.json({ ok: true });
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
571
|
+
res.status(500).json({ error: message });
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// GET /api/sessions/registered
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
/**
|
|
578
|
+
* Return the list of currently registered MCP session paths.
|
|
579
|
+
* An empty array means standalone mode (show all sessions from disk).
|
|
580
|
+
* A non-empty array means MCP mode (show only registered sessions).
|
|
581
|
+
*/
|
|
582
|
+
router.get('/sessions/registered', async (_req, res) => {
|
|
583
|
+
// Prune phantom sessions whose JSONL has not been modified in the last 5 minutes.
|
|
584
|
+
// Active sessions are written to frequently; stale ones left by SIGKILL won't be.
|
|
585
|
+
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
|
|
586
|
+
for (const registeredPath of registeredSessionPaths) {
|
|
587
|
+
try {
|
|
588
|
+
const stat = await fs.stat(registeredPath);
|
|
589
|
+
if (Date.now() - stat.mtime.getTime() > STALE_THRESHOLD_MS) {
|
|
590
|
+
registeredSessionPaths.delete(registeredPath);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// File no longer exists — remove the phantom entry
|
|
595
|
+
registeredSessionPaths.delete(registeredPath);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
res.json({ sessions: Array.from(registeredSessionPaths) });
|
|
599
|
+
});
|
|
412
600
|
return router;
|
|
413
601
|
}
|
|
@@ -139,7 +139,7 @@ function rowMatchesDirect(row, parsed) {
|
|
|
139
139
|
// Type filter (OR among typeFilters)
|
|
140
140
|
if (parsed.typeFilters.length > 0) {
|
|
141
141
|
const rowTypeLower = row.toolName.toLowerCase();
|
|
142
|
-
const rowKind = row.type; // 'agent' | 'tool'
|
|
142
|
+
const rowKind = row.type; // 'agent' | 'tool' | 'api-error'
|
|
143
143
|
const matched = parsed.typeFilters.some((tf) => rowTypeLower === tf || rowTypeLower.includes(tf) || rowKind === tf);
|
|
144
144
|
if (!matched)
|
|
145
145
|
return false;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getPricing, computeCost } from './token-cost.js';
|
|
1
2
|
// ---------------------------------------------------------------------------
|
|
2
3
|
// Type guards and helpers
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
@@ -70,6 +71,40 @@ function parseLine(line, idx) {
|
|
|
70
71
|
function isAgent(name) {
|
|
71
72
|
return name === 'Agent' || name === 'Task';
|
|
72
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Returns true when a tool result with is_error=true represents a tool execution
|
|
76
|
+
* failure (crash, timeout, permission denied) rather than a tool that ran and
|
|
77
|
+
* returned an error value. We distinguish by looking for crash/failure keywords
|
|
78
|
+
* in the output text.
|
|
79
|
+
*/
|
|
80
|
+
function isToolFailure(output) {
|
|
81
|
+
const lower = output.toLowerCase();
|
|
82
|
+
return (lower.includes('process exited with') ||
|
|
83
|
+
lower.includes('timed out') ||
|
|
84
|
+
lower.includes('timeout') ||
|
|
85
|
+
lower.includes('permission denied') ||
|
|
86
|
+
lower.includes('killed') ||
|
|
87
|
+
lower.includes('segmentation fault') ||
|
|
88
|
+
lower.includes('posttoolusedfailure') ||
|
|
89
|
+
lower.includes('tool execution failed') ||
|
|
90
|
+
lower.includes('failed to execute'));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Classify an API stop failure message into a short error class label.
|
|
94
|
+
* Returns e.g. "Rate Limit", "Billing Error", "Server Error", "Auth Error".
|
|
95
|
+
*/
|
|
96
|
+
function classifyStopFailure(message) {
|
|
97
|
+
const lower = message.toLowerCase();
|
|
98
|
+
if (lower.includes('rate limit') || lower.includes('rate_limit') || lower.includes('429'))
|
|
99
|
+
return 'Rate Limit';
|
|
100
|
+
if (lower.includes('billing') || lower.includes('payment') || lower.includes('credit'))
|
|
101
|
+
return 'Billing Error';
|
|
102
|
+
if (lower.includes('auth') || lower.includes('unauthorized') || lower.includes('403') || lower.includes('401'))
|
|
103
|
+
return 'Auth Error';
|
|
104
|
+
if (lower.includes('overloaded') || lower.includes('529'))
|
|
105
|
+
return 'Overloaded';
|
|
106
|
+
return 'Server Error';
|
|
107
|
+
}
|
|
73
108
|
// ---------------------------------------------------------------------------
|
|
74
109
|
// Public API
|
|
75
110
|
// ---------------------------------------------------------------------------
|
|
@@ -100,11 +135,15 @@ export function parseJsonlContent(content) {
|
|
|
100
135
|
for (const block of c) {
|
|
101
136
|
if (isObj(block) && block['type'] === 'tool_result' && typeof block['tool_use_id'] === 'string') {
|
|
102
137
|
const tb = block;
|
|
138
|
+
const output = extractContent(tb.content);
|
|
139
|
+
const isError = tb.is_error === true;
|
|
103
140
|
resultMap.set(tb.tool_use_id, {
|
|
104
141
|
endTime,
|
|
105
|
-
output
|
|
106
|
-
isError
|
|
142
|
+
output,
|
|
143
|
+
isError,
|
|
144
|
+
isFailure: isError && isToolFailure(output),
|
|
107
145
|
totalDurationMs: ur.toolUseResult?.totalDurationMs,
|
|
146
|
+
agentType: ur.toolUseResult?.agentType,
|
|
108
147
|
});
|
|
109
148
|
}
|
|
110
149
|
}
|
|
@@ -199,10 +238,12 @@ export function parseJsonlContent(content) {
|
|
|
199
238
|
const ar = rec;
|
|
200
239
|
const startTime = new Date(ar.timestamp).getTime();
|
|
201
240
|
const usage = ar.message.usage;
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
241
|
+
const cacheReadTokens = usage?.cache_read_input_tokens ?? 0;
|
|
242
|
+
const cacheCreateTokens = usage?.cache_creation_input_tokens ?? 0;
|
|
243
|
+
const rawInputTokens = usage?.input_tokens ?? 0;
|
|
244
|
+
const inputTokens = rawInputTokens + cacheCreateTokens + cacheReadTokens;
|
|
205
245
|
const outputTokens = usage?.output_tokens ?? 0;
|
|
246
|
+
const modelName = typeof ar.message.model === 'string' ? ar.message.model : null;
|
|
206
247
|
const contextFillPercent = 0; // recalculated after peak detection
|
|
207
248
|
for (const block of ar.message.content) {
|
|
208
249
|
if (!isToolUse(block))
|
|
@@ -234,6 +275,9 @@ export function parseJsonlContent(content) {
|
|
|
234
275
|
output: res ? res.output : null,
|
|
235
276
|
inputTokens,
|
|
236
277
|
outputTokens,
|
|
278
|
+
cacheReadTokens,
|
|
279
|
+
cacheCreateTokens,
|
|
280
|
+
modelName,
|
|
237
281
|
contextFillPercent,
|
|
238
282
|
isReread,
|
|
239
283
|
assistantUuid: ar.uuid,
|
|
@@ -275,6 +319,12 @@ export function parseJsonlContent(content) {
|
|
|
275
319
|
// First pass: create rows from top-level assistant records
|
|
276
320
|
const rowById = new Map();
|
|
277
321
|
for (const p of pending) {
|
|
322
|
+
const pricing = getPricing(p.modelName);
|
|
323
|
+
const rawInput = Math.max(0, p.inputTokens - p.cacheReadTokens - p.cacheCreateTokens);
|
|
324
|
+
const estimatedCost = p.inputTokens > 0 || p.outputTokens > 0
|
|
325
|
+
? computeCost(pricing, rawInput, p.outputTokens, p.cacheReadTokens, p.cacheCreateTokens)
|
|
326
|
+
: null;
|
|
327
|
+
const res = resultMap.get(p.id);
|
|
278
328
|
rowById.set(p.id, {
|
|
279
329
|
id: p.id,
|
|
280
330
|
type: isAgent(p.toolName) ? 'agent' : 'tool',
|
|
@@ -292,8 +342,12 @@ export function parseJsonlContent(content) {
|
|
|
292
342
|
tokenDelta: 0,
|
|
293
343
|
contextFillPercent: p.contextFillPercent,
|
|
294
344
|
isReread: p.isReread,
|
|
345
|
+
isFailure: res?.isFailure ?? false,
|
|
295
346
|
children: [],
|
|
296
347
|
tips: [],
|
|
348
|
+
modelName: p.modelName,
|
|
349
|
+
estimatedCost,
|
|
350
|
+
agentType: res?.agentType ?? null,
|
|
297
351
|
});
|
|
298
352
|
}
|
|
299
353
|
// Create rows from sub-agent progress records
|
|
@@ -326,8 +380,12 @@ export function parseJsonlContent(content) {
|
|
|
326
380
|
tokenDelta: 0,
|
|
327
381
|
contextFillPercent: ctxFill,
|
|
328
382
|
isReread,
|
|
383
|
+
isFailure: res ? (res.isError && isToolFailure(res.output)) : false,
|
|
329
384
|
children: [],
|
|
330
385
|
tips: [],
|
|
386
|
+
modelName: null,
|
|
387
|
+
estimatedCost: null,
|
|
388
|
+
agentType: null,
|
|
331
389
|
});
|
|
332
390
|
}
|
|
333
391
|
// Nest children: top-level rows use parentUuid ancestry, sub-agent rows use parentToolUseID
|
|
@@ -404,6 +462,52 @@ export function parseJsonlContent(content) {
|
|
|
404
462
|
}
|
|
405
463
|
}
|
|
406
464
|
computeDeltas(top);
|
|
465
|
+
// Detect API stop failures from system records (rate limit, billing, auth, server errors).
|
|
466
|
+
// These appear as system records with subtype 'stop_failure' or similar.
|
|
467
|
+
// We insert them as top-level 'api-error' rows at their point in time.
|
|
468
|
+
for (const rec of records) {
|
|
469
|
+
if (rec.type !== 'system')
|
|
470
|
+
continue;
|
|
471
|
+
const sr = rec;
|
|
472
|
+
const subtype = sr.subtype ?? '';
|
|
473
|
+
const isStopFailure = subtype === 'stop_failure' ||
|
|
474
|
+
subtype === 'api_error' ||
|
|
475
|
+
subtype === 'request_failed';
|
|
476
|
+
if (!isStopFailure)
|
|
477
|
+
continue;
|
|
478
|
+
const ts = new Date(sr.timestamp).getTime();
|
|
479
|
+
// Try to extract an error message from the record's top-level fields.
|
|
480
|
+
const raw = rec;
|
|
481
|
+
const errorMsg = (typeof raw['error'] === 'string' ? raw['error'] : null) ||
|
|
482
|
+
(typeof raw['message'] === 'string' ? raw['message'] : null) ||
|
|
483
|
+
subtype;
|
|
484
|
+
const errorClass = classifyStopFailure(errorMsg);
|
|
485
|
+
const rowId = `api-error-${sr.uuid}`;
|
|
486
|
+
top.push({
|
|
487
|
+
id: rowId,
|
|
488
|
+
type: 'api-error',
|
|
489
|
+
toolName: errorClass,
|
|
490
|
+
label: errorMsg,
|
|
491
|
+
startTime: ts,
|
|
492
|
+
endTime: ts,
|
|
493
|
+
duration: 0,
|
|
494
|
+
status: 'error',
|
|
495
|
+
parentAgentId: null,
|
|
496
|
+
input: {},
|
|
497
|
+
output: errorMsg,
|
|
498
|
+
inputTokens: 0,
|
|
499
|
+
outputTokens: 0,
|
|
500
|
+
tokenDelta: 0,
|
|
501
|
+
contextFillPercent: 0,
|
|
502
|
+
isReread: false,
|
|
503
|
+
isFailure: false,
|
|
504
|
+
children: [],
|
|
505
|
+
tips: [],
|
|
506
|
+
modelName: null,
|
|
507
|
+
estimatedCost: null,
|
|
508
|
+
agentType: null,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
407
511
|
return top;
|
|
408
512
|
}
|
|
409
513
|
/**
|
|
@@ -487,10 +591,13 @@ export function parseSubAgentContent(content) {
|
|
|
487
591
|
for (const block of c) {
|
|
488
592
|
if (isObj(block) && block['type'] === 'tool_result' && typeof block['tool_use_id'] === 'string') {
|
|
489
593
|
const tb = block;
|
|
594
|
+
const output = extractContent(tb.content);
|
|
595
|
+
const isError = tb.is_error === true;
|
|
490
596
|
resultMap.set(tb.tool_use_id, {
|
|
491
597
|
endTime,
|
|
492
|
-
output
|
|
493
|
-
isError
|
|
598
|
+
output,
|
|
599
|
+
isError,
|
|
600
|
+
isFailure: isError && isToolFailure(output),
|
|
494
601
|
});
|
|
495
602
|
}
|
|
496
603
|
}
|
|
@@ -517,11 +624,15 @@ export function parseSubAgentContent(content) {
|
|
|
517
624
|
const ar = rec;
|
|
518
625
|
const startTime = new Date(ar.timestamp).getTime();
|
|
519
626
|
const usage = ar.message.usage;
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
627
|
+
const cacheReadTokens = usage?.cache_read_input_tokens ?? 0;
|
|
628
|
+
const cacheCreateTokens = usage?.cache_creation_input_tokens ?? 0;
|
|
629
|
+
const rawInputTokens = usage?.input_tokens ?? 0;
|
|
630
|
+
const inputTokens = rawInputTokens + cacheCreateTokens + cacheReadTokens;
|
|
523
631
|
const outputTokens = usage?.output_tokens ?? 0;
|
|
632
|
+
const modelName = typeof ar.message.model === 'string' ? ar.message.model : null;
|
|
524
633
|
const contextFillPercent = (inputTokens / effectiveWindow) * 100;
|
|
634
|
+
const pricing = getPricing(modelName);
|
|
635
|
+
const rawInput = Math.max(0, rawInputTokens);
|
|
525
636
|
for (const block of ar.message.content) {
|
|
526
637
|
if (!isToolUse(block))
|
|
527
638
|
continue;
|
|
@@ -530,6 +641,9 @@ export function parseSubAgentContent(content) {
|
|
|
530
641
|
const isReread = fp !== null && seenPaths.has(fp);
|
|
531
642
|
if (fp !== null)
|
|
532
643
|
seenPaths.add(fp);
|
|
644
|
+
const estimatedCost = inputTokens > 0 || outputTokens > 0
|
|
645
|
+
? computeCost(pricing, rawInput, outputTokens, cacheReadTokens, cacheCreateTokens)
|
|
646
|
+
: null;
|
|
533
647
|
const res = resultMap.get(block.id);
|
|
534
648
|
rows.push({
|
|
535
649
|
id: block.id,
|
|
@@ -548,11 +662,57 @@ export function parseSubAgentContent(content) {
|
|
|
548
662
|
tokenDelta: 0,
|
|
549
663
|
contextFillPercent,
|
|
550
664
|
isReread,
|
|
665
|
+
isFailure: res?.isFailure ?? false,
|
|
551
666
|
children: [],
|
|
552
667
|
tips: [],
|
|
668
|
+
modelName,
|
|
669
|
+
estimatedCost,
|
|
670
|
+
agentType: null,
|
|
553
671
|
});
|
|
554
672
|
}
|
|
555
673
|
}
|
|
674
|
+
// Detect API stop failures within sub-agent content
|
|
675
|
+
for (const rec of records) {
|
|
676
|
+
if (rec.type !== 'system')
|
|
677
|
+
continue;
|
|
678
|
+
const sr = rec;
|
|
679
|
+
const subtype = sr.subtype ?? '';
|
|
680
|
+
const isStopFailure = subtype === 'stop_failure' ||
|
|
681
|
+
subtype === 'api_error' ||
|
|
682
|
+
subtype === 'request_failed';
|
|
683
|
+
if (!isStopFailure)
|
|
684
|
+
continue;
|
|
685
|
+
const ts = new Date(sr.timestamp).getTime();
|
|
686
|
+
const raw = rec;
|
|
687
|
+
const errorMsg = (typeof raw['error'] === 'string' ? raw['error'] : null) ||
|
|
688
|
+
(typeof raw['message'] === 'string' ? raw['message'] : null) ||
|
|
689
|
+
subtype;
|
|
690
|
+
const errorClass = classifyStopFailure(errorMsg);
|
|
691
|
+
rows.push({
|
|
692
|
+
id: `api-error-${sr.uuid}`,
|
|
693
|
+
type: 'api-error',
|
|
694
|
+
toolName: errorClass,
|
|
695
|
+
label: errorMsg,
|
|
696
|
+
startTime: ts,
|
|
697
|
+
endTime: ts,
|
|
698
|
+
duration: 0,
|
|
699
|
+
status: 'error',
|
|
700
|
+
parentAgentId: null,
|
|
701
|
+
input: {},
|
|
702
|
+
output: errorMsg,
|
|
703
|
+
inputTokens: 0,
|
|
704
|
+
outputTokens: 0,
|
|
705
|
+
tokenDelta: 0,
|
|
706
|
+
contextFillPercent: 0,
|
|
707
|
+
isReread: false,
|
|
708
|
+
isFailure: false,
|
|
709
|
+
children: [],
|
|
710
|
+
tips: [],
|
|
711
|
+
modelName: null,
|
|
712
|
+
estimatedCost: null,
|
|
713
|
+
agentType: null,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
556
716
|
// Compute per-row token delta for sub-agent rows
|
|
557
717
|
const sorted = [...rows].sort((a, b) => a.startTime - b.startTime);
|
|
558
718
|
let prevInput = 0;
|
|
@@ -563,6 +723,85 @@ export function parseSubAgentContent(content) {
|
|
|
563
723
|
}
|
|
564
724
|
return rows;
|
|
565
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Parse instruction-loading records from JSONL content.
|
|
728
|
+
* Looks for system records with subtype containing "instructions" or similar patterns,
|
|
729
|
+
* as well as user records with isMeta=true that describe loaded CLAUDE.md files.
|
|
730
|
+
* Returns a deduplicated list of InstructionFile entries. Never throws.
|
|
731
|
+
*/
|
|
732
|
+
export function parseInstructionsLoaded(content) {
|
|
733
|
+
const lines = content.split('\n');
|
|
734
|
+
const seen = new Set();
|
|
735
|
+
const result = [];
|
|
736
|
+
for (let i = 0; i < lines.length; i++) {
|
|
737
|
+
const line = lines[i].trim();
|
|
738
|
+
if (!line)
|
|
739
|
+
continue;
|
|
740
|
+
let parsed;
|
|
741
|
+
try {
|
|
742
|
+
parsed = JSON.parse(line);
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (!isObj(parsed))
|
|
748
|
+
continue;
|
|
749
|
+
const recordType = parsed['type'];
|
|
750
|
+
const subtype = typeof parsed['subtype'] === 'string' ? parsed['subtype'] : null;
|
|
751
|
+
// Match system records that describe loaded instruction files
|
|
752
|
+
if (recordType === 'system') {
|
|
753
|
+
// Claude Code may emit system records with subtype: "instructions_loaded",
|
|
754
|
+
// "claude_md_loaded", "context_loaded", or similar
|
|
755
|
+
const isInstructionRecord = subtype !== null && (subtype.includes('instruction') ||
|
|
756
|
+
subtype.includes('claude_md') ||
|
|
757
|
+
subtype.includes('context_load') ||
|
|
758
|
+
subtype.includes('system_prompt'));
|
|
759
|
+
if (isInstructionRecord) {
|
|
760
|
+
const filePath = typeof parsed['filePath'] === 'string' ? parsed['filePath']
|
|
761
|
+
: typeof parsed['file_path'] === 'string' ? parsed['file_path']
|
|
762
|
+
: typeof parsed['path'] === 'string' ? parsed['path']
|
|
763
|
+
: null;
|
|
764
|
+
if (!filePath || seen.has(filePath))
|
|
765
|
+
continue;
|
|
766
|
+
seen.add(filePath);
|
|
767
|
+
const loadReason = typeof parsed['reason'] === 'string' ? parsed['reason']
|
|
768
|
+
: typeof parsed['load_reason'] === 'string' ? parsed['load_reason']
|
|
769
|
+
: subtype ?? 'session_start';
|
|
770
|
+
const estimatedTokens = typeof parsed['tokens'] === 'number' ? parsed['tokens']
|
|
771
|
+
: typeof parsed['token_count'] === 'number' ? parsed['token_count']
|
|
772
|
+
: null;
|
|
773
|
+
const parentFilePath = typeof parsed['parentFilePath'] === 'string' ? parsed['parentFilePath']
|
|
774
|
+
: typeof parsed['parent_file_path'] === 'string' ? parsed['parent_file_path']
|
|
775
|
+
: null;
|
|
776
|
+
result.push({ filePath, loadReason, estimatedTokens, parentFilePath });
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
// Match user records with isMeta=true that list loaded context files
|
|
781
|
+
if (recordType === 'user' && parsed['isMeta'] === true) {
|
|
782
|
+
const metaContent = parsed['content'];
|
|
783
|
+
const contentStr = typeof metaContent === 'string' ? metaContent : '';
|
|
784
|
+
if (!contentStr)
|
|
785
|
+
continue;
|
|
786
|
+
// Look for patterns like "Loaded CLAUDE.md from /path/to/CLAUDE.md"
|
|
787
|
+
// or "Instructions loaded: /path/to/CLAUDE.md"
|
|
788
|
+
const filePathMatches = contentStr.matchAll(/(?:loaded|reading|including)[:\s]+([^\s,\n]+\.md)/gi);
|
|
789
|
+
for (const match of filePathMatches) {
|
|
790
|
+
const filePath = match[1];
|
|
791
|
+
if (!filePath || seen.has(filePath))
|
|
792
|
+
continue;
|
|
793
|
+
seen.add(filePath);
|
|
794
|
+
result.push({
|
|
795
|
+
filePath,
|
|
796
|
+
loadReason: 'session_start',
|
|
797
|
+
estimatedTokens: null,
|
|
798
|
+
parentFilePath: null,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return result;
|
|
804
|
+
}
|
|
566
805
|
/**
|
|
567
806
|
* Extract a mapping of Agent/Task tool_use IDs to sub-agent IDs from session content.
|
|
568
807
|
* Returns a Map where keys are the parent tool_use.id values and values are agentId strings
|