noctrace 0.7.4 → 0.8.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/.claude-plugin/plugin.json +7 -7
- package/README.md +67 -38
- package/dist/client/assets/index-DwPuae45.css +2 -0
- package/dist/client/assets/index-x60cSMi2.js +30 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/routes/api.js +79 -4
- package/dist/server/server/watcher.js +2 -1
- package/dist/server/server/ws.js +1 -1
- package/dist/server/shared/otlp-export.js +116 -0
- package/dist/server/shared/parser.js +132 -16
- package/dist/server/shared/reliability.js +186 -0
- package/dist/server/shared/session-metadata.js +161 -0
- package/hooks/hooks.json +33 -0
- package/package.json +15 -6
- package/dist/client/assets/index-BPKebIZj.js +0 -30
- package/dist/client/assets/index-DyjeNSzP.css +0 -2
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-x60cSMi2.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DwPuae45.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="root"></div>
|
|
@@ -7,10 +7,12 @@ import fs from 'node:fs/promises';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { WebSocket } from 'ws';
|
|
9
9
|
import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, parseInstructionsLoaded, } from '../../shared/parser.js';
|
|
10
|
+
import { parseSessionResultMetrics, parseInitContext } from '../../shared/session-metadata.js';
|
|
10
11
|
import { computeContextHealth } from '../../shared/health.js';
|
|
11
12
|
import { parseAssistantTurns, computeDrift } from '../../shared/drift.js';
|
|
12
13
|
import { attachEfficiencyTips } from '../../shared/tips.js';
|
|
13
14
|
import { attachSecurityTips } from '../../shared/security-tips.js';
|
|
15
|
+
import { sessionToOtlp } from '../../shared/otlp-export.js';
|
|
14
16
|
/**
|
|
15
17
|
* Read ~/.claude/sessions/*.json and return a Set of sessionIds
|
|
16
18
|
* whose PID is still a running claude process.
|
|
@@ -222,17 +224,34 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
222
224
|
catch {
|
|
223
225
|
// config.json missing or malformed — include team with empty members
|
|
224
226
|
}
|
|
225
|
-
// Count task files
|
|
227
|
+
// Count task files and parse their contents from ~/.claude/tasks/{team-name}/
|
|
226
228
|
let taskCount = 0;
|
|
229
|
+
const tasks = [];
|
|
227
230
|
const teamTasksDir = path.join(tasksDir, teamName);
|
|
228
231
|
try {
|
|
229
232
|
const taskFiles = await fs.readdir(teamTasksDir);
|
|
230
233
|
taskCount = taskFiles.length;
|
|
234
|
+
for (const tf of taskFiles) {
|
|
235
|
+
if (!tf.endsWith('.json'))
|
|
236
|
+
continue;
|
|
237
|
+
try {
|
|
238
|
+
const taskRaw = await fs.readFile(path.join(teamTasksDir, tf), 'utf8');
|
|
239
|
+
const taskData = JSON.parse(taskRaw);
|
|
240
|
+
tasks.push({
|
|
241
|
+
id: tf.replace(/\.json$/, ''),
|
|
242
|
+
subject: typeof taskData['subject'] === 'string' ? taskData['subject'] : tf,
|
|
243
|
+
status: typeof taskData['status'] === 'string' ? taskData['status'] : 'pending',
|
|
244
|
+
assignedTo: typeof taskData['assigned_to'] === 'string' ? taskData['assigned_to']
|
|
245
|
+
: typeof taskData['assignedTo'] === 'string' ? taskData['assignedTo'] : null,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
catch { /* skip malformed task files */ }
|
|
249
|
+
}
|
|
231
250
|
}
|
|
232
251
|
catch {
|
|
233
252
|
// Task directory doesn't exist — taskCount stays 0
|
|
234
253
|
}
|
|
235
|
-
teams.push({ name: teamName, members, taskCount });
|
|
254
|
+
teams.push({ name: teamName, members, taskCount, tasks });
|
|
236
255
|
}
|
|
237
256
|
res.json(teams);
|
|
238
257
|
}
|
|
@@ -382,6 +401,43 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
382
401
|
}
|
|
383
402
|
});
|
|
384
403
|
// ---------------------------------------------------------------------------
|
|
404
|
+
// GET /api/session/:slug/:id/otlp (MUST be before :slug/:id to avoid param capture)
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
/**
|
|
407
|
+
* Export a session as OTLP/HTTP JSON trace format.
|
|
408
|
+
* The response can be POSTed directly to any OTLP collector at /v1/traces.
|
|
409
|
+
*/
|
|
410
|
+
router.get('/session/:slug/:id/otlp', async (req, res) => {
|
|
411
|
+
const { slug, id } = req.params;
|
|
412
|
+
const filePath = path.join(projectsDir, slug, `${id}.jsonl`);
|
|
413
|
+
try {
|
|
414
|
+
assertWithinBase(filePath, projectsDir);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
let content;
|
|
422
|
+
try {
|
|
423
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
res.status(404).json({ error: `Session not found: ${slug}/${id}` });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const rows = parseJsonlContent(content);
|
|
430
|
+
const sessionId = extractSessionId(content) ?? id;
|
|
431
|
+
const otlp = sessionToOtlp(rows, sessionId);
|
|
432
|
+
res.setHeader('Content-Type', 'application/json');
|
|
433
|
+
res.json(otlp);
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
437
|
+
res.status(500).json({ error: message });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
385
441
|
// GET /api/session/:slug/:id
|
|
386
442
|
// ---------------------------------------------------------------------------
|
|
387
443
|
/**
|
|
@@ -414,6 +470,8 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
414
470
|
const turns = parseAssistantTurns(content);
|
|
415
471
|
const drift = computeDrift(turns);
|
|
416
472
|
const instructionsLoaded = parseInstructionsLoaded(content);
|
|
473
|
+
const resultMetrics = parseSessionResultMetrics(content);
|
|
474
|
+
const initContext = parseInitContext(content);
|
|
417
475
|
// Load sub-agent JSONL files and attach as children to matching agent rows
|
|
418
476
|
const subagentsDir = path.join(projectsDir, slug, id, 'subagents');
|
|
419
477
|
let subagentsDirExists = false;
|
|
@@ -468,7 +526,8 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
468
526
|
}
|
|
469
527
|
}
|
|
470
528
|
// Attach efficiency tips to wasteful rows (mutates rows in place)
|
|
471
|
-
|
|
529
|
+
// tips.ts expects number[] (timestamps), so extract from CompactionBoundary[]
|
|
530
|
+
attachEfficiencyTips(rows, boundaries.map((b) => b.timestamp));
|
|
472
531
|
// Attach security tips (mutates rows in place)
|
|
473
532
|
attachSecurityTips(rows);
|
|
474
533
|
// Count total tips across all rows (including children) for the client toolbar
|
|
@@ -476,7 +535,7 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
476
535
|
return r.reduce((sum, row) => sum + row.tips.length + countTips(row.children), 0);
|
|
477
536
|
}
|
|
478
537
|
const tipCount = countTips(rows);
|
|
479
|
-
res.json({ rows, compactionBoundaries: boundaries, health, sessionId, drift, tipCount, instructionsLoaded });
|
|
538
|
+
res.json({ rows, compactionBoundaries: boundaries, health, sessionId, drift, tipCount, instructionsLoaded, resultMetrics, initContext });
|
|
480
539
|
}
|
|
481
540
|
catch (err) {
|
|
482
541
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -509,6 +568,22 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
509
568
|
...(typeof body['permission_mode'] === 'string' ? { permission_mode: body['permission_mode'] } : {}),
|
|
510
569
|
received_at: new Date().toISOString(),
|
|
511
570
|
};
|
|
571
|
+
// For SubagentStart events, broadcast a separate message type
|
|
572
|
+
// so the client can show an in-progress agent row immediately
|
|
573
|
+
if (event.hook_event_name === 'SubagentStart' && event.agent_id) {
|
|
574
|
+
const subagentMsg = {
|
|
575
|
+
type: 'subagent-start',
|
|
576
|
+
agentId: event.agent_id,
|
|
577
|
+
agentType: event.agent_type ?? null,
|
|
578
|
+
sessionId: event.session_id,
|
|
579
|
+
};
|
|
580
|
+
const subPayload = JSON.stringify(subagentMsg);
|
|
581
|
+
for (const client of wss.clients) {
|
|
582
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
583
|
+
client.send(subPayload);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
512
587
|
const message = { type: 'hook-event', event };
|
|
513
588
|
const payload = JSON.stringify(message);
|
|
514
589
|
for (const client of wss.clients) {
|
|
@@ -67,7 +67,8 @@ export function watchSession(filePath, callbacks) {
|
|
|
67
67
|
const turns = parseAssistantTurns(fullContent);
|
|
68
68
|
const drift = computeDrift(turns);
|
|
69
69
|
// Attach efficiency tips to wasteful rows (mutates allRows in place)
|
|
70
|
-
|
|
70
|
+
// tips.ts expects number[] (timestamps), so extract from CompactionBoundary[]
|
|
71
|
+
attachEfficiencyTips(allRows, boundaries.map((b) => b.timestamp));
|
|
71
72
|
// Attach security tips (mutates allRows in place)
|
|
72
73
|
attachSecurityTips(allRows);
|
|
73
74
|
callbacks.onNewRows(allRows, health, boundaries, drift);
|
package/dist/server/server/ws.js
CHANGED
|
@@ -151,7 +151,7 @@ export function setupWebSocket(server, claudeHome) {
|
|
|
151
151
|
try {
|
|
152
152
|
const proc = spawn('claude', args, {
|
|
153
153
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
154
|
-
env: {
|
|
154
|
+
env: { PATH: process.env['PATH'] ?? '', HOME: process.env['HOME'] ?? '', CLAUDE_HOME: process.env['CLAUDE_HOME'] ?? '' },
|
|
155
155
|
});
|
|
156
156
|
resumeProc = proc;
|
|
157
157
|
// Buffer for incomplete lines from chunked TCP data
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** OTLP span status code for OK */
|
|
2
|
+
const STATUS_OK = 1;
|
|
3
|
+
/** OTLP span status code for ERROR */
|
|
4
|
+
const STATUS_ERROR = 2;
|
|
5
|
+
/** Convert Unix ms timestamp to OTLP nanosecond string */
|
|
6
|
+
function toNanos(ms) {
|
|
7
|
+
return (BigInt(ms) * BigInt(1_000_000)).toString();
|
|
8
|
+
}
|
|
9
|
+
/** Generate a 16-byte hex trace ID from session ID */
|
|
10
|
+
function traceIdFromSession(sessionId) {
|
|
11
|
+
const hex = sessionId.replace(/[^a-f0-9]/gi, '');
|
|
12
|
+
return (hex + '0'.repeat(32)).slice(0, 32);
|
|
13
|
+
}
|
|
14
|
+
/** Generate an 8-byte hex span ID from row ID */
|
|
15
|
+
function spanIdFromRow(rowId) {
|
|
16
|
+
const hex = rowId.replace(/[^a-f0-9]/gi, '');
|
|
17
|
+
return (hex + '0'.repeat(16)).slice(0, 16);
|
|
18
|
+
}
|
|
19
|
+
function strAttr(key, value) {
|
|
20
|
+
return { key, value: { stringValue: value } };
|
|
21
|
+
}
|
|
22
|
+
function intAttr(key, value) {
|
|
23
|
+
return { key, value: { intValue: String(value) } };
|
|
24
|
+
}
|
|
25
|
+
function boolAttr(key, value) {
|
|
26
|
+
return { key, value: { boolValue: value } };
|
|
27
|
+
}
|
|
28
|
+
function doubleAttr(key, value) {
|
|
29
|
+
return { key, value: { doubleValue: value } };
|
|
30
|
+
}
|
|
31
|
+
/** Convert a WaterfallRow to an OTLP span object */
|
|
32
|
+
function rowToSpan(row, sessionId, parentSpanId) {
|
|
33
|
+
const traceId = traceIdFromSession(sessionId);
|
|
34
|
+
const spanId = spanIdFromRow(row.id);
|
|
35
|
+
const endTime = row.endTime ?? row.startTime;
|
|
36
|
+
const attributes = [
|
|
37
|
+
strAttr('tool.name', row.toolName),
|
|
38
|
+
strAttr('tool.type', row.type),
|
|
39
|
+
strAttr('tool.label', row.label),
|
|
40
|
+
strAttr('tool.status', row.status),
|
|
41
|
+
intAttr('token.input', row.inputTokens),
|
|
42
|
+
intAttr('token.output', row.outputTokens),
|
|
43
|
+
intAttr('token.delta', row.tokenDelta),
|
|
44
|
+
doubleAttr('context.fill_percent', row.contextFillPercent),
|
|
45
|
+
boolAttr('tool.is_reread', row.isReread),
|
|
46
|
+
boolAttr('tool.is_failure', row.isFailure),
|
|
47
|
+
];
|
|
48
|
+
if (row.modelName)
|
|
49
|
+
attributes.push(strAttr('model.name', row.modelName));
|
|
50
|
+
if (row.estimatedCost != null)
|
|
51
|
+
attributes.push(doubleAttr('cost.usd', row.estimatedCost));
|
|
52
|
+
if (row.agentType)
|
|
53
|
+
attributes.push(strAttr('agent.type', row.agentType));
|
|
54
|
+
if (row.isFastMode)
|
|
55
|
+
attributes.push(boolAttr('model.fast_mode', true));
|
|
56
|
+
if (row.duration != null)
|
|
57
|
+
attributes.push(intAttr('duration_ms', row.duration));
|
|
58
|
+
const span = {
|
|
59
|
+
traceId,
|
|
60
|
+
spanId,
|
|
61
|
+
name: row.type === 'agent' ? `agent.${row.toolName}` : `tool.${row.toolName}`,
|
|
62
|
+
kind: 1, // SPAN_KIND_INTERNAL
|
|
63
|
+
startTimeUnixNano: toNanos(row.startTime),
|
|
64
|
+
endTimeUnixNano: toNanos(endTime),
|
|
65
|
+
attributes,
|
|
66
|
+
status: {
|
|
67
|
+
code: row.status === 'error' ? STATUS_ERROR : STATUS_OK,
|
|
68
|
+
...(row.status === 'error' && row.output ? { message: row.output.slice(0, 256) } : {}),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
if (parentSpanId) {
|
|
72
|
+
span['parentSpanId'] = parentSpanId;
|
|
73
|
+
}
|
|
74
|
+
return span;
|
|
75
|
+
}
|
|
76
|
+
/** Recursively flatten rows and their children into a flat array of OTLP spans */
|
|
77
|
+
function flattenSpans(rows, sessionId, parentSpanId) {
|
|
78
|
+
const spans = [];
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const spanId = spanIdFromRow(row.id);
|
|
81
|
+
spans.push(rowToSpan(row, sessionId, parentSpanId));
|
|
82
|
+
if (row.children.length > 0) {
|
|
83
|
+
spans.push(...flattenSpans(row.children, sessionId, spanId));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return spans;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Convert a noctrace session to OTLP/HTTP JSON trace export format.
|
|
90
|
+
* The output can be POSTed directly to any OTLP/HTTP collector at /v1/traces.
|
|
91
|
+
*
|
|
92
|
+
* @param rows - Parsed waterfall rows from parseJsonlContent()
|
|
93
|
+
* @param sessionId - Session UUID used to derive the OTLP trace ID
|
|
94
|
+
*/
|
|
95
|
+
export function sessionToOtlp(rows, sessionId) {
|
|
96
|
+
const spans = flattenSpans(rows, sessionId, null);
|
|
97
|
+
return {
|
|
98
|
+
resourceSpans: [
|
|
99
|
+
{
|
|
100
|
+
resource: {
|
|
101
|
+
attributes: [
|
|
102
|
+
strAttr('service.name', 'noctrace'),
|
|
103
|
+
strAttr('service.version', '0.7.5'),
|
|
104
|
+
strAttr('session.id', sessionId),
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
scopeSpans: [
|
|
108
|
+
{
|
|
109
|
+
scope: { name: 'noctrace', version: '0.7.5' },
|
|
110
|
+
spans,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getPricing, computeCost } from './token-cost.js';
|
|
2
|
+
// Re-export from session-metadata (moved to reduce file size)
|
|
3
|
+
export { parseCompactionBoundaries } from './session-metadata.js';
|
|
2
4
|
// ---------------------------------------------------------------------------
|
|
3
5
|
// Type guards and helpers
|
|
4
6
|
// ---------------------------------------------------------------------------
|
|
@@ -64,7 +66,7 @@ function parseLine(line, idx) {
|
|
|
64
66
|
return null;
|
|
65
67
|
}
|
|
66
68
|
const type = parsed['type'];
|
|
67
|
-
if (type !== 'assistant' && type !== 'user' && type !== 'system' && type !== 'progress')
|
|
69
|
+
if (type !== 'assistant' && type !== 'user' && type !== 'system' && type !== 'progress' && type !== 'result')
|
|
68
70
|
return null;
|
|
69
71
|
return parsed;
|
|
70
72
|
}
|
|
@@ -105,6 +107,22 @@ function classifyStopFailure(message) {
|
|
|
105
107
|
return 'Overloaded';
|
|
106
108
|
return 'Server Error';
|
|
107
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Map structured assistant.error field to a display label.
|
|
112
|
+
* Falls back to classifyStopFailure for unknown values.
|
|
113
|
+
*/
|
|
114
|
+
function classifyAssistantError(errorField) {
|
|
115
|
+
const map = {
|
|
116
|
+
rate_limit: 'Rate Limit',
|
|
117
|
+
billing_error: 'Billing Error',
|
|
118
|
+
authentication_failed: 'Auth Error',
|
|
119
|
+
server_error: 'Server Error',
|
|
120
|
+
invalid_request: 'Invalid Request',
|
|
121
|
+
max_output_tokens: 'Max Tokens',
|
|
122
|
+
unknown: 'Server Error',
|
|
123
|
+
};
|
|
124
|
+
return map[errorField] ?? classifyStopFailure(errorField);
|
|
125
|
+
}
|
|
108
126
|
// ---------------------------------------------------------------------------
|
|
109
127
|
// Public API
|
|
110
128
|
// ---------------------------------------------------------------------------
|
|
@@ -245,6 +263,9 @@ export function parseJsonlContent(content) {
|
|
|
245
263
|
const inputTokens = rawInputTokens + cacheCreateTokens + cacheReadTokens;
|
|
246
264
|
const outputTokens = usage?.output_tokens ?? 0;
|
|
247
265
|
const modelName = typeof ar.message.model === 'string' ? ar.message.model : null;
|
|
266
|
+
const isFastMode = ar.message.speed === 'fast';
|
|
267
|
+
const parentToolUseId = typeof ar.parent_tool_use_id === 'string' ? ar.parent_tool_use_id : null;
|
|
268
|
+
const sequence = typeof ar.sequence === 'number' ? ar.sequence : null;
|
|
248
269
|
const contextFillPercent = 0; // recalculated after peak detection
|
|
249
270
|
for (const block of ar.message.content) {
|
|
250
271
|
if (!isToolUse(block))
|
|
@@ -283,6 +304,9 @@ export function parseJsonlContent(content) {
|
|
|
283
304
|
isReread,
|
|
284
305
|
assistantUuid: ar.uuid,
|
|
285
306
|
assistantParentUuid: ar.parentUuid,
|
|
307
|
+
sequence,
|
|
308
|
+
isFastMode,
|
|
309
|
+
parentToolUseId,
|
|
286
310
|
});
|
|
287
311
|
}
|
|
288
312
|
}
|
|
@@ -350,6 +374,9 @@ export function parseJsonlContent(content) {
|
|
|
350
374
|
estimatedCost,
|
|
351
375
|
agentType: res?.agentType ?? null,
|
|
352
376
|
agentColor: res?.agentColor ?? null,
|
|
377
|
+
sequence: p.sequence,
|
|
378
|
+
isFastMode: p.isFastMode,
|
|
379
|
+
parentToolUseId: p.parentToolUseId,
|
|
353
380
|
});
|
|
354
381
|
}
|
|
355
382
|
// Create rows from sub-agent progress records
|
|
@@ -389,6 +416,9 @@ export function parseJsonlContent(content) {
|
|
|
389
416
|
estimatedCost: null,
|
|
390
417
|
agentType: null,
|
|
391
418
|
agentColor: null,
|
|
419
|
+
sequence: null,
|
|
420
|
+
isFastMode: false,
|
|
421
|
+
parentToolUseId: null,
|
|
392
422
|
});
|
|
393
423
|
}
|
|
394
424
|
// Nest children: top-level rows use parentUuid ancestry, sub-agent rows use parentToolUseID
|
|
@@ -422,7 +452,7 @@ export function parseJsonlContent(content) {
|
|
|
422
452
|
// Sort children by startTime within each agent
|
|
423
453
|
for (const row of rowById.values()) {
|
|
424
454
|
if (row.children.length > 1) {
|
|
425
|
-
row.children.sort((a, b) => a.startTime - b.startTime);
|
|
455
|
+
row.children.sort((a, b) => a.startTime !== b.startTime ? a.startTime - b.startTime : (a.sequence ?? 0) - (b.sequence ?? 0));
|
|
426
456
|
}
|
|
427
457
|
}
|
|
428
458
|
// Stretch agent rows to span from dispatch to last child completion
|
|
@@ -510,23 +540,103 @@ export function parseJsonlContent(content) {
|
|
|
510
540
|
estimatedCost: null,
|
|
511
541
|
agentType: null,
|
|
512
542
|
agentColor: null,
|
|
543
|
+
sequence: null,
|
|
544
|
+
isFastMode: false,
|
|
545
|
+
parentToolUseId: null,
|
|
513
546
|
});
|
|
514
547
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const
|
|
525
|
-
if
|
|
526
|
-
|
|
548
|
+
// Detect API errors from typed assistant.error field (newer Claude Code versions)
|
|
549
|
+
for (const rec of records) {
|
|
550
|
+
if (rec.type !== 'assistant')
|
|
551
|
+
continue;
|
|
552
|
+
const ar = rec;
|
|
553
|
+
if (!ar.message.error)
|
|
554
|
+
continue;
|
|
555
|
+
const ts = new Date(ar.timestamp).getTime();
|
|
556
|
+
const errorClass = classifyAssistantError(ar.message.error);
|
|
557
|
+
const rowId = `api-error-asst-${ar.uuid}`;
|
|
558
|
+
// Skip if a system-record api-error already exists at this timestamp (avoid duplicates)
|
|
559
|
+
if (top.some((r) => r.type === 'api-error' && Math.abs(r.startTime - ts) < 1000))
|
|
560
|
+
continue;
|
|
561
|
+
top.push({
|
|
562
|
+
id: rowId,
|
|
563
|
+
type: 'api-error',
|
|
564
|
+
toolName: errorClass,
|
|
565
|
+
label: ar.message.error,
|
|
566
|
+
startTime: ts,
|
|
567
|
+
endTime: ts,
|
|
568
|
+
duration: 0,
|
|
569
|
+
status: 'error',
|
|
570
|
+
parentAgentId: null,
|
|
571
|
+
input: {},
|
|
572
|
+
output: ar.message.error,
|
|
573
|
+
inputTokens: 0,
|
|
574
|
+
outputTokens: 0,
|
|
575
|
+
tokenDelta: 0,
|
|
576
|
+
contextFillPercent: 0,
|
|
577
|
+
isReread: false,
|
|
578
|
+
isFailure: false,
|
|
579
|
+
children: [],
|
|
580
|
+
tips: [],
|
|
581
|
+
modelName: null,
|
|
582
|
+
estimatedCost: null,
|
|
583
|
+
agentType: null,
|
|
584
|
+
agentColor: null,
|
|
585
|
+
sequence: typeof ar.sequence === 'number' ? ar.sequence : null,
|
|
586
|
+
isFastMode: false,
|
|
587
|
+
parentToolUseId: null,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
// Detect hook lifecycle events from system records
|
|
591
|
+
const hookStartMap = new Map(); // hookKey → startTime
|
|
592
|
+
for (const rec of records) {
|
|
593
|
+
if (rec.type !== 'system')
|
|
594
|
+
continue;
|
|
595
|
+
const sr = rec;
|
|
596
|
+
const subtype = sr.subtype ?? '';
|
|
597
|
+
if (subtype !== 'hook_started' && subtype !== 'hook_response')
|
|
598
|
+
continue;
|
|
599
|
+
const raw = rec;
|
|
600
|
+
const hookName = typeof raw['hook_name'] === 'string' ? raw['hook_name'] : subtype;
|
|
601
|
+
const hookId = typeof raw['hook_id'] === 'string' ? raw['hook_id'] : sr.uuid;
|
|
602
|
+
const ts = new Date(sr.timestamp).getTime();
|
|
603
|
+
if (subtype === 'hook_started') {
|
|
604
|
+
hookStartMap.set(hookId, ts);
|
|
605
|
+
}
|
|
606
|
+
else if (subtype === 'hook_response') {
|
|
607
|
+
const startTs = hookStartMap.get(hookId) ?? ts;
|
|
608
|
+
const duration = ts - startTs;
|
|
609
|
+
top.push({
|
|
610
|
+
id: `hook-${hookId}`,
|
|
611
|
+
type: 'hook',
|
|
612
|
+
toolName: hookName,
|
|
613
|
+
label: `Hook: ${hookName}`,
|
|
614
|
+
startTime: startTs,
|
|
615
|
+
endTime: ts,
|
|
616
|
+
duration,
|
|
617
|
+
status: 'success',
|
|
618
|
+
parentAgentId: null,
|
|
619
|
+
input: {},
|
|
620
|
+
output: null,
|
|
621
|
+
inputTokens: 0,
|
|
622
|
+
outputTokens: 0,
|
|
623
|
+
tokenDelta: 0,
|
|
624
|
+
contextFillPercent: 0,
|
|
625
|
+
isReread: false,
|
|
626
|
+
isFailure: false,
|
|
627
|
+
children: [],
|
|
628
|
+
tips: [],
|
|
629
|
+
modelName: null,
|
|
630
|
+
estimatedCost: null,
|
|
631
|
+
agentType: null,
|
|
632
|
+
agentColor: null,
|
|
633
|
+
sequence: typeof sr.sequence === 'number' ? sr.sequence : null,
|
|
634
|
+
isFastMode: false,
|
|
635
|
+
parentToolUseId: null,
|
|
636
|
+
});
|
|
527
637
|
}
|
|
528
638
|
}
|
|
529
|
-
return
|
|
639
|
+
return top;
|
|
530
640
|
}
|
|
531
641
|
/**
|
|
532
642
|
* Extract the sessionId from the first valid record. Returns null if none found.
|
|
@@ -673,6 +783,9 @@ export function parseSubAgentContent(content) {
|
|
|
673
783
|
estimatedCost,
|
|
674
784
|
agentType: null,
|
|
675
785
|
agentColor: null,
|
|
786
|
+
sequence: null,
|
|
787
|
+
isFastMode: false,
|
|
788
|
+
parentToolUseId: null,
|
|
676
789
|
});
|
|
677
790
|
}
|
|
678
791
|
}
|
|
@@ -717,6 +830,9 @@ export function parseSubAgentContent(content) {
|
|
|
717
830
|
estimatedCost: null,
|
|
718
831
|
agentType: null,
|
|
719
832
|
agentColor: null,
|
|
833
|
+
sequence: null,
|
|
834
|
+
isFastMode: false,
|
|
835
|
+
parentToolUseId: null,
|
|
720
836
|
});
|
|
721
837
|
}
|
|
722
838
|
// Compute per-row token delta for sub-agent rows
|
|
@@ -784,7 +900,7 @@ export function parseInstructionsLoaded(content) {
|
|
|
784
900
|
}
|
|
785
901
|
}
|
|
786
902
|
// Match user records with isMeta=true that list loaded context files
|
|
787
|
-
if (recordType === 'user' && parsed['isMeta'] === true) {
|
|
903
|
+
if (recordType === 'user' && (parsed['isMeta'] === true || parsed['isSynthetic'] === true)) {
|
|
788
904
|
const metaContent = parsed['content'];
|
|
789
905
|
const contentStr = typeof metaContent === 'string' ? metaContent : '';
|
|
790
906
|
if (!contentStr)
|