agent-tracer 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.
@@ -0,0 +1,631 @@
1
+ 'use strict';
2
+ /**
3
+ * In-memory session state, hook event handler, and SSE broadcast.
4
+ *
5
+ * Call createStore(dbExports) once at startup.
6
+ * All HTTP routes that need to read/mutate sessions should call store methods.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const {
13
+ isValidSessionId, safeFilePath, summarize, findTranscript,
14
+ parseTranscriptMeta, parseTranscriptCompactions,
15
+ parseTranscriptStats, parseFullSessionStats,
16
+ findChildSessionIds, PROJECTS_DIR,
17
+ } = require('./parser');
18
+
19
+ function createStore({ db, stmts, persistSession, persistTool, persistCompaction }) {
20
+
21
+ // ── In-memory state ────────────────────────────────────────────────────────
22
+ const sessions = new Map(); // sessionId → AgentNode
23
+ const toolCallMap = new Map(); // toolUseId → { sessionId, call }
24
+ const sessionList = []; // ordered root session IDs
25
+ const clients = []; // SSE response objects
26
+ let compactSeq = 0; // monotonic counter for fallback compaction IDs
27
+
28
+ // ── SSE broadcast ──────────────────────────────────────────────────────────
29
+ function broadcast() {
30
+ const data = `data: ${serializeTree()}\n\n`;
31
+ for (let i = clients.length - 1; i >= 0; i--) {
32
+ try { clients[i].write(data); }
33
+ catch { clients.splice(i, 1); }
34
+ }
35
+ }
36
+
37
+ // ── Load history from DB on startup ───────────────────────────────────────
38
+ function loadHistory() {
39
+ const roots = stmts.listRootSessions.all();
40
+ for (const row of roots) loadSessionTree(row.id);
41
+ for (const row of [...roots].reverse()) {
42
+ if (!sessionList.includes(row.id)) sessionList.push(row.id);
43
+ }
44
+ }
45
+
46
+ function loadSessionTree(sessionId) {
47
+ if (sessions.has(sessionId)) return sessions.get(sessionId);
48
+
49
+ const row = stmts.getSession.get(sessionId);
50
+ if (!row) return null;
51
+
52
+ const toolRows = stmts.getToolCalls.all(sessionId);
53
+ const compRows = stmts.getCompactions.all(sessionId);
54
+ const childRows = stmts.getChildren.all(sessionId);
55
+
56
+ const node = {
57
+ sessionId: row.id,
58
+ parentSessionId: row.parent_id || null,
59
+ label: row.label,
60
+ status: row.status,
61
+ startedAt: row.started_at,
62
+ endedAt: row.ended_at || null,
63
+ tokens: { input: row.tokens_in, output: row.tokens_out, cacheRead: row.cache_read },
64
+ costUsd: row.cost_usd,
65
+ lastText: row.last_text || '',
66
+ cwd: row.cwd || null,
67
+ permissionMode: row.permission_mode || null,
68
+ entrypoint: row.entrypoint || null,
69
+ version: row.version || null,
70
+ gitBranch: row.git_branch || null,
71
+ packageJson: row.package_json ? (() => { try { return JSON.parse(row.package_json); } catch { return null; } })() : null,
72
+ toolCalls: toolRows.map(t => ({
73
+ id: t.id, name: t.name, summary: t.summary,
74
+ input: t.input_json ? (() => { try { return JSON.parse(t.input_json); } catch { return {}; } })() : {},
75
+ done: !!t.done || row.status !== 'running',
76
+ startedAt: t.started_at, durationMs: t.duration_ms,
77
+ })),
78
+ compactions: compRows.map(c => ({
79
+ id: c.id, timestamp: c.timestamp,
80
+ tokensBefore: c.tokens_before, tokensAfter: c.tokens_after,
81
+ summary: c.summary || '',
82
+ })),
83
+ children: childRows.map(c => c.id),
84
+ };
85
+
86
+ sessions.set(sessionId, node);
87
+ for (const child of childRows) loadSessionTree(child.id);
88
+ return node;
89
+ }
90
+
91
+ // ── Startup backfill (costs, meta, compactions) ────────────────────────────
92
+ function backfillCosts() {
93
+ for (const node of sessions.values()) {
94
+ const needsCost = node.costUsd === 0 && node.tokens.input === 0;
95
+ const needsMeta = !node.permissionMode && !node.entrypoint;
96
+ const needsLabel = node.label === `agent-${node.sessionId.slice(0, 8)}`;
97
+ const needsCompacts = node.compactions.length === 0;
98
+ if (!needsCost && !needsMeta && !needsLabel && !needsCompacts) continue;
99
+
100
+ const transcript = findTranscript(node.sessionId, node.cwd);
101
+ if (!transcript) continue;
102
+
103
+ let changed = false;
104
+ if (needsCost) {
105
+ const stats = parseFullSessionStats(transcript, node.sessionId);
106
+ if (stats.inputTokens > 0 || stats.outputTokens > 0) {
107
+ node.tokens.input = stats.inputTokens;
108
+ node.tokens.output = stats.outputTokens;
109
+ node.tokens.cacheRead = stats.cacheReadTokens;
110
+ node.costUsd = stats.costUsd;
111
+ if (node.status === 'running') node.status = 'done';
112
+ console.log(` Backfilled ${node.sessionId.slice(0, 8)}: $${stats.costUsd.toFixed(4)}`);
113
+ changed = true;
114
+ }
115
+ }
116
+ if (needsMeta || needsLabel) {
117
+ const tmeta = parseTranscriptMeta(transcript);
118
+ if (needsMeta) {
119
+ if (tmeta.permissionMode) { node.permissionMode = tmeta.permissionMode; changed = true; }
120
+ if (tmeta.entrypoint) { node.entrypoint = tmeta.entrypoint; changed = true; }
121
+ if (tmeta.version) { node.version = tmeta.version; changed = true; }
122
+ if (tmeta.gitBranch) { node.gitBranch = tmeta.gitBranch; changed = true; }
123
+ }
124
+ if (needsLabel && tmeta.label) { node.label = tmeta.label; changed = true; }
125
+ }
126
+ if (needsCompacts) {
127
+ const compacts = parseTranscriptCompactions(transcript);
128
+ for (const c of compacts) {
129
+ node.compactions.push(c);
130
+ persistCompaction(c, node.sessionId);
131
+ }
132
+ if (compacts.length > 0) changed = true;
133
+ }
134
+ if (changed) persistSession(node);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Discover worktree / background agent sessions from all project directories
140
+ * and synthesise virtual sidechain nodes for inline agents with no own file.
141
+ */
142
+ function discoverChildSessions() {
143
+ const knownIds = new Set(sessions.keys());
144
+
145
+ // Gather unrecognised JSONL files across all project dirs
146
+ const candidates = [];
147
+ try {
148
+ for (const dirEntry of fs.readdirSync(PROJECTS_DIR)) {
149
+ const projectDir = path.join(PROJECTS_DIR, dirEntry);
150
+ try { if (!fs.statSync(projectDir).isDirectory()) continue; } catch { continue; }
151
+ let files;
152
+ try { files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl')); } catch { continue; }
153
+ for (const file of files) {
154
+ const childId = file.replace('.jsonl', '');
155
+ if (!isValidSessionId(childId) || knownIds.has(childId)) continue;
156
+ const childPath = path.join(projectDir, file);
157
+ if (!safeFilePath(childPath)) continue;
158
+ candidates.push({ childId, childPath });
159
+ }
160
+ }
161
+ } catch {}
162
+
163
+ function readChildMeta(childPath) {
164
+ let firstPrompt = null, startedAt = null, endedAt = null, cwd = null;
165
+ try {
166
+ const lines = fs.readFileSync(childPath, 'utf8').split('\n');
167
+ for (const line of lines.slice(0, 30)) {
168
+ if (!line.trim()) continue;
169
+ try {
170
+ const obj = JSON.parse(line);
171
+ if (!startedAt && obj.timestamp) startedAt = new Date(obj.timestamp).getTime();
172
+ if (!cwd && obj.cwd) cwd = obj.cwd;
173
+ if (!firstPrompt && obj.type === 'user' && obj.message?.content) {
174
+ const c = obj.message.content;
175
+ const text = typeof c === 'string' ? c
176
+ : (Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('') : '');
177
+ if (text && !text.startsWith('<') && !text.startsWith('/') && text.length > 10)
178
+ firstPrompt = text.trim();
179
+ }
180
+ if (firstPrompt && startedAt && cwd) break;
181
+ } catch {}
182
+ }
183
+ for (let i = lines.length - 1; i >= 0; i--) {
184
+ const l = lines[i].trim();
185
+ if (!l) continue;
186
+ try { const obj = JSON.parse(l); if (obj.timestamp) { endedAt = new Date(obj.timestamp).getTime(); break; } } catch {}
187
+ }
188
+ } catch {}
189
+ return { firstPrompt, startedAt, endedAt, cwd };
190
+ }
191
+
192
+ for (const node of [...sessions.values()]) {
193
+ const transcript = findTranscript(node.sessionId, node.cwd);
194
+ if (!transcript) continue;
195
+
196
+ const agentCalls = [];
197
+ const agentResults = new Map();
198
+
199
+ try {
200
+ for (const line of fs.readFileSync(transcript, 'utf8').split('\n')) {
201
+ if (!line.trim()) continue;
202
+ try {
203
+ const obj = JSON.parse(line);
204
+ for (const block of (obj.message?.content || [])) {
205
+ if ((block.name === 'Agent' || block.name === 'Task') && block.input?.prompt) {
206
+ agentCalls.push({
207
+ toolUseId: block.id,
208
+ prompt: block.input.prompt,
209
+ description: block.input.description || '',
210
+ isBackground: !!block.input.run_in_background,
211
+ isolation: block.input.isolation || null,
212
+ });
213
+ }
214
+ if (block.type === 'tool_result') {
215
+ const txt = Array.isArray(block.content)
216
+ ? block.content.filter(b => b.type === 'text').map(b => b.text).join('')
217
+ : (typeof block.content === 'string' ? block.content : '');
218
+ if (txt) agentResults.set(block.tool_use_id, txt);
219
+ }
220
+ }
221
+ } catch {}
222
+ }
223
+ } catch {}
224
+
225
+ if (!agentCalls.length) continue;
226
+
227
+ const matchedToolUseIds = new Set();
228
+
229
+ for (const { childId, childPath } of candidates) {
230
+ if (sessions.has(childId)) continue;
231
+ const { firstPrompt, startedAt, endedAt, cwd: childCwd } = readChildMeta(childPath);
232
+ if (!firstPrompt) continue;
233
+
234
+ const needle = firstPrompt.slice(0, 80).toLowerCase();
235
+ let matchedCall = null;
236
+ for (const call of agentCalls) {
237
+ const hay = call.prompt.slice(0, 80).toLowerCase();
238
+ if (hay === needle || needle.startsWith(hay.slice(0, 50)) || hay.startsWith(needle.slice(0, 50))) {
239
+ matchedCall = call; break;
240
+ }
241
+ }
242
+ if (!matchedCall) continue;
243
+
244
+ matchedToolUseIds.add(matchedCall.toolUseId);
245
+ const childMeta = parseTranscriptMeta(childPath);
246
+ const childStats = parseTranscriptStats(childPath);
247
+ const childCompacts = parseTranscriptCompactions(childPath);
248
+ const label = (matchedCall.description || matchedCall.prompt).replace(/\s+/g, ' ').slice(0, 50);
249
+ const isWorktree = matchedCall.isolation === 'worktree';
250
+
251
+ const childNode = {
252
+ sessionId: childId,
253
+ parentSessionId: node.sessionId,
254
+ label,
255
+ status: 'done',
256
+ startedAt: startedAt || node.startedAt,
257
+ endedAt: endedAt || Date.now(),
258
+ tokens: { input: childStats.inputTokens, output: childStats.outputTokens, cacheRead: childStats.cacheReadTokens },
259
+ costUsd: childStats.costUsd,
260
+ lastText: '',
261
+ cwd: childCwd || node.cwd,
262
+ permissionMode: childMeta?.permissionMode || null,
263
+ entrypoint: childMeta?.entrypoint || null,
264
+ version: childMeta?.version || null,
265
+ gitBranch: childMeta?.gitBranch || null,
266
+ packageJson: null,
267
+ toolCalls: [],
268
+ compactions: childCompacts,
269
+ children: [],
270
+ isWorktree,
271
+ isSidechain: false,
272
+ };
273
+ sessions.set(childId, childNode);
274
+ knownIds.add(childId);
275
+ if (!node.children.includes(childId)) node.children.push(childId);
276
+ persistSession(childNode);
277
+ for (const c of childCompacts) persistCompaction(c, childId);
278
+ console.log(` Discovered ${isWorktree ? 'worktree' : 'background'} session ${childId.slice(0, 8)} → parent ${node.sessionId.slice(0, 8)}`);
279
+ }
280
+
281
+ for (const call of agentCalls) {
282
+ if (matchedToolUseIds.has(call.toolUseId)) continue;
283
+ if (call.isBackground) continue;
284
+
285
+ const virtualId = `sc-${node.sessionId.slice(0, 8)}-${call.toolUseId.slice(-12)}`;
286
+ if (sessions.has(virtualId)) continue;
287
+
288
+ const resultText = agentResults.get(call.toolUseId) || '';
289
+ const isError = /^Error:|permission/i.test(resultText);
290
+
291
+ const virtualNode = {
292
+ sessionId: virtualId,
293
+ parentSessionId: node.sessionId,
294
+ label: (call.description || call.prompt).replace(/\s+/g, ' ').slice(0, 60),
295
+ status: isError ? 'error' : 'done',
296
+ startedAt: node.startedAt,
297
+ endedAt: node.endedAt || Date.now(),
298
+ tokens: { input: 0, output: 0, cacheRead: 0 },
299
+ costUsd: 0,
300
+ lastText: resultText.slice(0, 300),
301
+ cwd: node.cwd,
302
+ permissionMode: null,
303
+ entrypoint: null,
304
+ version: null,
305
+ gitBranch: null,
306
+ packageJson: null,
307
+ toolCalls: [],
308
+ compactions: [],
309
+ children: [],
310
+ isWorktree: false,
311
+ isSidechain: true,
312
+ };
313
+ sessions.set(virtualId, virtualNode);
314
+ knownIds.add(virtualId);
315
+ if (!node.children.includes(virtualId)) node.children.push(virtualId);
316
+ console.log(` Synthesised sidechain ${virtualId} → parent ${node.sessionId.slice(0, 8)}`);
317
+ }
318
+ }
319
+ }
320
+
321
+ // ── Live cost refresh (every 45s) ─────────────────────────────────────────
322
+ function refreshLiveCosts() {
323
+ let changed = false;
324
+ for (const node of sessions.values()) {
325
+ if (node.status !== 'running') continue;
326
+ const transcript = findTranscript(node.sessionId, node.cwd);
327
+ if (!transcript) continue;
328
+ try {
329
+ const stats = parseFullSessionStats(transcript, node.sessionId);
330
+ if (stats.costUsd !== node.costUsd || stats.inputTokens !== node.tokens.input) {
331
+ node.tokens.input = stats.inputTokens;
332
+ node.tokens.output = stats.outputTokens;
333
+ node.tokens.cacheRead = stats.cacheReadTokens;
334
+ node.costUsd = stats.costUsd;
335
+ persistSession(node);
336
+ changed = true;
337
+ }
338
+ } catch {}
339
+ }
340
+ if (changed) broadcast();
341
+ }
342
+
343
+ // ── Serialization ──────────────────────────────────────────────────────────
344
+ function serializeSession(sessionId, _visited = new Set()) {
345
+ if (_visited.has(sessionId)) return null; // cycle guard
346
+ _visited.add(sessionId);
347
+ const n = sessions.get(sessionId);
348
+ if (!n) return null;
349
+ return {
350
+ sessionId: n.sessionId,
351
+ label: n.label,
352
+ status: n.status,
353
+ startedAt: n.startedAt,
354
+ endedAt: n.endedAt,
355
+ tokens: n.tokens,
356
+ costUsd: n.costUsd,
357
+ lastText: n.lastText.slice(-200),
358
+ permissionMode: n.permissionMode || null,
359
+ entrypoint: n.entrypoint || null,
360
+ version: n.version || null,
361
+ gitBranch: n.gitBranch || null,
362
+ cwd: n.cwd || null,
363
+ compactions: n.compactions || [],
364
+ isSidechain: n.isSidechain || false,
365
+ isWorktree: n.isWorktree || false,
366
+ toolCalls: n.toolCalls.map(tc => ({
367
+ id: tc.id, name: tc.name, done: tc.done,
368
+ summary: tc.summary, input: tc.input, durationMs: tc.durationMs,
369
+ startedAt: tc.startedAt || null,
370
+ })),
371
+ children: n.children.map(cid => serializeSession(cid, _visited)).filter(Boolean),
372
+ };
373
+ }
374
+
375
+ function serializeTree(rootId = null) {
376
+ const roots = rootId ? [rootId] : [...sessionList].reverse();
377
+ const serializedRoots = roots.map(id => serializeSession(id)).filter(Boolean);
378
+ const allNodes = [...sessions.values()];
379
+ const rootNodes = allNodes.filter(n => !n.parentSessionId);
380
+ const totals = {
381
+ agents: allNodes.length,
382
+ tools: allNodes.reduce((s, n) => s + n.toolCalls.length, 0),
383
+ tokensIn: rootNodes.reduce((s, n) => s + n.tokens.input, 0),
384
+ tokensOut: rootNodes.reduce((s, n) => s + n.tokens.output, 0),
385
+ cacheRead: rootNodes.reduce((s, n) => s + n.tokens.cacheRead, 0),
386
+ cost: rootNodes.reduce((s, n) => s + n.costUsd, 0),
387
+ };
388
+ return JSON.stringify({ roots: serializedRoots, root: serializedRoots[0] || null, totals });
389
+ }
390
+
391
+ // ── In-memory factory ─────────────────────────────────────────────────────
392
+ function getOrCreate(sessionId, parentSessionId = null, label = null) {
393
+ if (!sessions.has(sessionId)) {
394
+ const node = {
395
+ sessionId,
396
+ parentSessionId,
397
+ label: label || `agent-${sessionId.slice(0, 8)}`,
398
+ status: 'running',
399
+ startedAt: Date.now(),
400
+ endedAt: null,
401
+ tokens: { input: 0, output: 0, cacheRead: 0 },
402
+ costUsd: 0,
403
+ toolCalls: [],
404
+ compactions: [],
405
+ lastText: '',
406
+ children: [],
407
+ cwd: null,
408
+ permissionMode: null,
409
+ entrypoint: null,
410
+ version: null,
411
+ gitBranch: null,
412
+ };
413
+ sessions.set(sessionId, node);
414
+ persistSession(node);
415
+ if (!parentSessionId) {
416
+ sessionList.push(sessionId);
417
+ } else {
418
+ const parent = sessions.get(parentSessionId);
419
+ if (parent && !parent.children.includes(sessionId)) parent.children.push(sessionId);
420
+ }
421
+ }
422
+ return sessions.get(sessionId);
423
+ }
424
+
425
+ // ── Hook event handler ────────────────────────────────────────────────────
426
+ function handleHook(ev) {
427
+ const {
428
+ hook_event_name: event,
429
+ session_id: sid,
430
+ parent_session_id: parentSid,
431
+ tool_name,
432
+ tool_input,
433
+ tool_use_id,
434
+ agent_description,
435
+ duration_ms,
436
+ is_error,
437
+ compact_summary,
438
+ tokens_before,
439
+ tokens_after,
440
+ } = ev;
441
+
442
+ if (!sid) return;
443
+
444
+ // Capture permission mode
445
+ if (ev.permission_mode || ev.permissionMode) {
446
+ const node = sessions.get(sid) || getOrCreate(sid, parentSid || null);
447
+ if (!node.permissionMode) node.permissionMode = ev.permission_mode || ev.permissionMode;
448
+ }
449
+
450
+ // Capture cwd and snapshot project files
451
+ if (ev.cwd) {
452
+ const node = sessions.get(sid) || getOrCreate(sid, parentSid || null);
453
+ if (!node.cwd) {
454
+ node.cwd = ev.cwd;
455
+ try {
456
+ const load = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; } };
457
+ const ps = load(path.join(ev.cwd, '.claude', 'settings.json'));
458
+ const pls = load(path.join(ev.cwd, '.claude', 'settings.local.json'));
459
+ const allow = [...(ps.permissions?.allow || []), ...(pls.permissions?.allow || [])];
460
+ const deny = [...(ps.permissions?.deny || []), ...(pls.permissions?.deny || [])];
461
+ if (allow.length || deny.length) {
462
+ node.projectAllow = [...new Set(allow)];
463
+ node.projectDeny = [...new Set(deny)];
464
+ }
465
+ try {
466
+ const pkg = load(path.join(ev.cwd, 'package.json'));
467
+ if (pkg.name) node.packageJson = { name: pkg.name, version: pkg.version,
468
+ deps: pkg.dependencies || {}, devDeps: pkg.devDependencies || {} };
469
+ } catch {}
470
+ try {
471
+ const reqs = fs.readFileSync(path.join(ev.cwd, 'requirements.txt'), 'utf8');
472
+ node.requirementsTxt = reqs.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
473
+ } catch {}
474
+ } catch {}
475
+ persistSession(node);
476
+ }
477
+ }
478
+
479
+ switch (event) {
480
+
481
+ case 'PreToolUse': {
482
+ const node = getOrCreate(sid, parentSid || null);
483
+ if (!tool_use_id || !tool_name) break;
484
+ const tc = {
485
+ id: tool_use_id,
486
+ name: tool_name,
487
+ input: tool_input || {},
488
+ summary: summarize(tool_name, tool_input),
489
+ done: false,
490
+ startedAt: Date.now(),
491
+ durationMs: null,
492
+ sessionId: sid,
493
+ };
494
+ node.toolCalls.push(tc);
495
+ toolCallMap.set(tool_use_id, { sessionId: sid, call: tc });
496
+ persistTool(tc);
497
+ if ((tool_name === 'Agent' || tool_name === 'Task') && agent_description) {
498
+ const childId = `child-${tool_use_id}`;
499
+ getOrCreate(childId, sid, agent_description);
500
+ tc.childSessionId = childId;
501
+ }
502
+ break;
503
+ }
504
+
505
+ case 'PostToolUse': {
506
+ const entry = toolCallMap.get(tool_use_id);
507
+ if (entry) {
508
+ entry.call.done = true;
509
+ entry.call.durationMs = duration_ms || (Date.now() - entry.call.startedAt);
510
+ persistTool(entry.call);
511
+ }
512
+ const node = sessions.get(sid);
513
+ if (node) {
514
+ node._toolsSinceRefresh = (node._toolsSinceRefresh || 0) + 1;
515
+ if (node._toolsSinceRefresh >= 5) {
516
+ node._toolsSinceRefresh = 0;
517
+ const transcript = findTranscript(sid, node.cwd);
518
+ if (transcript) {
519
+ try {
520
+ const stats = parseFullSessionStats(transcript, sid);
521
+ node.tokens.input = stats.inputTokens;
522
+ node.tokens.output = stats.outputTokens;
523
+ node.tokens.cacheRead = stats.cacheReadTokens;
524
+ node.costUsd = stats.costUsd;
525
+ persistSession(node);
526
+ } catch {}
527
+ }
528
+ }
529
+ }
530
+ break;
531
+ }
532
+
533
+ case 'Stop': {
534
+ const node = sessions.get(sid);
535
+ if (node) {
536
+ node.status = is_error ? 'error' : 'done';
537
+ node.endedAt = Date.now();
538
+ node.lastText = '';
539
+ for (const tc of node.toolCalls) {
540
+ if (!tc.done) { tc.done = true; tc.durationMs = Date.now() - tc.startedAt; persistTool(tc); }
541
+ }
542
+ const transcript = findTranscript(sid, node.cwd);
543
+ if (transcript) {
544
+ try {
545
+ const tmeta = parseTranscriptMeta(transcript);
546
+ if (tmeta.permissionMode) node.permissionMode = tmeta.permissionMode;
547
+ if (tmeta.entrypoint) node.entrypoint = tmeta.entrypoint;
548
+ if (tmeta.version) node.version = tmeta.version;
549
+ if (tmeta.gitBranch) node.gitBranch = tmeta.gitBranch;
550
+ const stats = parseFullSessionStats(transcript, sid);
551
+ node.tokens.input = stats.inputTokens;
552
+ node.tokens.output = stats.outputTokens;
553
+ node.tokens.cacheRead = stats.cacheReadTokens;
554
+ node.costUsd = stats.costUsd;
555
+ } catch {}
556
+ }
557
+ persistSession(node);
558
+ }
559
+ break;
560
+ }
561
+
562
+ case 'PreCompact': {
563
+ const node = getOrCreate(sid, parentSid || null);
564
+ const cid = `compact-${sid}-${tool_use_id || ++compactSeq}`;
565
+ if (!node.compactions.find(x => x.id === cid)) {
566
+ const c = {
567
+ id: cid,
568
+ timestamp: Date.now(),
569
+ summary: '',
570
+ tokensBefore: tokens_before || ev.input_tokens_before || null,
571
+ tokensAfter: null,
572
+ _toolUseId: tool_use_id || null,
573
+ };
574
+ node.compactions.push(c);
575
+ persistCompaction(c, sid);
576
+ }
577
+ break;
578
+ }
579
+
580
+ case 'PostCompact': {
581
+ const node = sessions.get(sid);
582
+ if (!node) break;
583
+ const existing = node.compactions.find(x =>
584
+ (tool_use_id && x._toolUseId === tool_use_id) ||
585
+ (!tool_use_id && x.tokensAfter === null && x.summary === '')
586
+ );
587
+ if (existing) {
588
+ existing.summary = compact_summary || ev.summary || '';
589
+ existing.tokensAfter = tokens_after || ev.input_tokens_after || null;
590
+ existing.tokensBefore = existing.tokensBefore || tokens_before || null;
591
+ persistCompaction(existing, sid);
592
+ } else {
593
+ const c = {
594
+ id: `compact-${sid}-${tool_use_id || ++compactSeq}`,
595
+ timestamp: Date.now(),
596
+ summary: compact_summary || ev.summary || '',
597
+ tokensBefore: tokens_before || null,
598
+ tokensAfter: tokens_after || null,
599
+ };
600
+ if (!node.compactions.find(x => x.id === c.id)) {
601
+ node.compactions.push(c);
602
+ persistCompaction(c, sid);
603
+ }
604
+ }
605
+ persistSession(node);
606
+ break;
607
+ }
608
+
609
+ default: {
610
+ if (event) console.warn(`[hook] unknown event: ${event}`);
611
+ break;
612
+ }
613
+ }
614
+
615
+ broadcast();
616
+ }
617
+
618
+ return {
619
+ // State (read-only externally — mutate via methods)
620
+ sessions, toolCallMap, sessionList, clients,
621
+ // Lifecycle
622
+ loadHistory, loadSessionTree,
623
+ backfillCosts, discoverChildSessions, refreshLiveCosts,
624
+ // Serialization & broadcast
625
+ serializeSession, serializeTree, broadcast,
626
+ // Event handler
627
+ getOrCreate, handleHook,
628
+ };
629
+ }
630
+
631
+ module.exports = { createStore };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "agent-tracer",
3
+ "version": "0.2.0",
4
+ "description": "Live trace viewer for Claude Code agent execution — shows subagent tree, tool calls, tokens, and cost in real time",
5
+ "main": "bin/agent-trace-daemon.js",
6
+ "bin": {
7
+ "agent-trace-daemon": "bin/agent-trace-daemon.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/agent-trace-daemon.js",
11
+ "daemon": "node bin/agent-trace-daemon.js",
12
+ "install-hooks": "node bin/agent-trace-daemon.js --install",
13
+ "test": "vitest run --reporter=verbose"
14
+ },
15
+ "files": [
16
+ "bin/agent-trace-daemon.js",
17
+ "lib",
18
+ "public",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "claude",
23
+ "claude-code",
24
+ "agent",
25
+ "trace",
26
+ "subagent",
27
+ "observability",
28
+ "hooks"
29
+ ],
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "dependencies": {
35
+ "better-sqlite3": "^12.8.0"
36
+ },
37
+ "devDependencies": {
38
+ "vitest": "^4.1.2"
39
+ }
40
+ }