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.
@@ -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-DqY0cF0g.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-C4vi082v.css">
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: extractContent(tb.content),
106
- isError: tb.is_error === true,
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 inputTokens = (usage?.input_tokens ?? 0)
203
- + (usage?.cache_creation_input_tokens ?? 0)
204
- + (usage?.cache_read_input_tokens ?? 0);
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: extractContent(tb.content),
493
- isError: tb.is_error === true,
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 inputTokens = (usage?.input_tokens ?? 0)
521
- + (usage?.cache_creation_input_tokens ?? 0)
522
- + (usage?.cache_read_input_tokens ?? 0);
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