ccanalyzer 1.0.1 → 1.1.1

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/bin/index.js CHANGED
@@ -5,7 +5,7 @@ const { startServer } = require('../src/server');
5
5
  const port = parseInt(process.env.PORT || '3737', 10);
6
6
 
7
7
  startServer(port).then(({ url }) => {
8
- console.log(`\n ccanalyser running at ${url}\n`);
8
+ console.log(`\n ccanalyzer running at ${url}\n`);
9
9
  try {
10
10
  // Try to open browser
11
11
  import('open').then(m => m.default(url)).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccanalyzer",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Web-based analyzer for Claude Code sessions",
5
5
  "bin": {
6
6
  "ccanalyzer": "./bin/index.js"
package/src/parser.js CHANGED
@@ -164,6 +164,7 @@ function parseSubagents(sessionDir) {
164
164
  try { meta = JSON.parse(fs.readFileSync(path.join(subagentsDir, metaFile), 'utf8')); } catch {}
165
165
 
166
166
  const parsed = parseAgentFile(jsonlPath);
167
+ const { skills } = collectToolsUsed(jsonlPath);
167
168
  agents.push({
168
169
  agentId,
169
170
  meta,
@@ -173,6 +174,7 @@ function parseSubagents(sessionDir) {
173
174
  totalUsage: parsed.totalUsage,
174
175
  totalCost: parsed.totalCost,
175
176
  model: parsed.model,
177
+ skillsUsed: [...skills],
176
178
  });
177
179
  }
178
180
 
@@ -278,6 +280,25 @@ function getAllProjects() {
278
280
  return projects;
279
281
  }
280
282
 
283
+ function collectToolsUsed(filePath) {
284
+ const mcps = new Set();
285
+ const skills = new Set();
286
+ for (const entry of parseLines(filePath)) {
287
+ if (entry.type !== 'assistant') continue;
288
+ for (const block of (entry.message?.content || [])) {
289
+ if (!block || block.type !== 'tool_use') continue;
290
+ if (block.name.startsWith('mcp__')) {
291
+ const parts = block.name.split('__');
292
+ const server = (parts[1] || '').replace(/^claude_ai_/, '').replace(/_/g, ' ');
293
+ if (server) mcps.add(server);
294
+ } else if (block.name === 'Skill' && block.input?.skill) {
295
+ skills.add(block.input.skill);
296
+ }
297
+ }
298
+ }
299
+ return { mcps, skills };
300
+ }
301
+
281
302
  function getSessionDetail(dirName, sessionFile) {
282
303
  const filePath = path.join(PROJECTS_DIR, dirName, sessionFile);
283
304
  if (!fs.existsSync(filePath)) throw new Error('Session not found');
@@ -297,7 +318,22 @@ function getSessionDetail(dirName, sessionFile) {
297
318
  }
298
319
  }
299
320
 
300
- return { ...session, agents };
321
+ // Aggregate MCPs and skills from main session + all subagent files
322
+ const allMcps = new Set();
323
+ const allSkills = new Set();
324
+ const filesToScan = [filePath];
325
+ const subagentsDir = path.join(sessionDir, 'subagents');
326
+ if (fs.existsSync(subagentsDir)) {
327
+ fs.readdirSync(subagentsDir).filter(f => f.endsWith('.jsonl'))
328
+ .forEach(f => filesToScan.push(path.join(subagentsDir, f)));
329
+ }
330
+ for (const f of filesToScan) {
331
+ const { mcps, skills } = collectToolsUsed(f);
332
+ mcps.forEach(m => allMcps.add(m));
333
+ skills.forEach(s => allSkills.add(s));
334
+ }
335
+
336
+ return { ...session, agents, sessionMcps: [...allMcps], sessionSkills: [...allSkills] };
301
337
  }
302
338
 
303
339
  function getAgentDetail(dirName, sessionFile, agentId) {
package/src/public/app.js CHANGED
@@ -62,6 +62,27 @@ function showView(name) {
62
62
  });
63
63
  }
64
64
 
65
+ function pushUrl(params) {
66
+ const qs = new URLSearchParams(params).toString();
67
+ history.pushState(params, '', qs ? '?' + qs : location.pathname);
68
+ }
69
+
70
+ function restoreFromUrl() {
71
+ const p = new URLSearchParams(location.search);
72
+ const project = p.get('project');
73
+ const session = p.get('session');
74
+ if (project && session) return loadSessionDetail(encodeURIComponent(project), encodeURIComponent(session));
75
+ if (project) return loadSessions(encodeURIComponent(project));
76
+ loadDashboard();
77
+ }
78
+
79
+ window.addEventListener('popstate', e => {
80
+ const d = e.state || {};
81
+ if (d.session && d.project) loadSessionDetail(encodeURIComponent(d.project), encodeURIComponent(d.session));
82
+ else if (d.project) loadSessions(encodeURIComponent(d.project));
83
+ else loadDashboard();
84
+ });
85
+
65
86
  function setBreadcrumb(parts) {
66
87
  $('breadcrumb').innerHTML = parts.map(p => `<div style="color:var(--text3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p)}</div>`).join('');
67
88
  }
@@ -75,6 +96,7 @@ async function api(path) {
75
96
 
76
97
  /* ── Dashboard ── */
77
98
  async function loadDashboard() {
99
+ pushUrl({});
78
100
  showView('dashboard');
79
101
  setBreadcrumb(['Dashboard']);
80
102
  const container = $('view-dashboard');
@@ -178,6 +200,7 @@ function initActivityChart() {
178
200
  /* ── Sessions list ── */
179
201
  async function loadSessions(dirNameEncoded) {
180
202
  const dirName = decodeURIComponent(dirNameEncoded);
203
+ pushUrl({ project: dirName });
181
204
  showView('sessions');
182
205
 
183
206
  if (!state.projects) {
@@ -230,6 +253,7 @@ async function loadSessions(dirNameEncoded) {
230
253
  async function loadSessionDetail(dirNameEncoded, fileEncoded) {
231
254
  const dirName = decodeURIComponent(dirNameEncoded);
232
255
  const file = decodeURIComponent(fileEncoded);
256
+ pushUrl({ project: dirName, session: file });
233
257
  showView('session-detail');
234
258
  const container = $('view-session-detail');
235
259
  container.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text3)">Chargement...</div>';
@@ -252,6 +276,20 @@ async function loadSessionDetail(dirNameEncoded, fileEncoded) {
252
276
  state.currentProject = state.projects.find(p => p.dirName === dirName);
253
277
  }
254
278
 
279
+ // Build map: message uuid → skills used by agents spawned from that message
280
+ state.agentSkillsByUuid = {};
281
+ if (session.agents) {
282
+ for (const agent of session.agents) {
283
+ if (agent.spawnedByUuid && agent.skillsUsed?.length) {
284
+ const existing = state.agentSkillsByUuid[agent.spawnedByUuid] || [];
285
+ for (const s of agent.skillsUsed) {
286
+ if (!existing.includes(s)) existing.push(s);
287
+ }
288
+ state.agentSkillsByUuid[agent.spawnedByUuid] = existing;
289
+ }
290
+ }
291
+ }
292
+
255
293
  setBreadcrumb([state.currentProject?.path || dirName, session.title]);
256
294
  renderSessionDetail(session, dirName, file);
257
295
  }
@@ -264,6 +302,14 @@ function renderSessionDetail(session, dirName, file) {
264
302
  ? new Date(lastTimestamp) - new Date(firstTimestamp) : null;
265
303
  const hasAgents = agents && agents.length > 0;
266
304
 
305
+ // MCPs and skills pre-aggregated server-side (main + all subagents)
306
+ const mcpBar = session.sessionMcps?.length
307
+ ? `<div class="session-tools-bar">${session.sessionMcps.map(s => `<span class="usage-chip"><span class="tag-mcp">mcp</span>${escHtml(s)}</span>`).join('')}</div>`
308
+ : '';
309
+ const skillBar = session.sessionSkills?.length
310
+ ? `<div class="session-tools-bar">${session.sessionSkills.map(s => `<span class="usage-chip"><span class="tag-skill">skill</span>${escHtml(s)}</span>`).join('')}</div>`
311
+ : '';
312
+
267
313
  container.innerHTML = `
268
314
  <button class="back-btn" onclick="loadSessions('${encodeURIComponent(dirName)}')">← Sessions</button>
269
315
  <div class="page-header">
@@ -278,6 +324,7 @@ function renderSessionDetail(session, dirName, file) {
278
324
  ${cwd ? `<div class="meta-item"><div class="meta-label">Répertoire</div><div class="meta-value mono">${escHtml(cwd.replace('/home/olivier-j/', '~/'))}</div></div>` : ''}
279
325
  ${gitBranch && gitBranch !== 'HEAD' ? `<div class="meta-item"><div class="meta-label">Branche</div><div class="meta-value mono" style="color:var(--green)">${escHtml(gitBranch)}</div></div>` : ''}
280
326
  </div>
327
+ ${mcpBar}${skillBar}
281
328
 
282
329
  <div class="token-bar">
283
330
  <div class="token-item"><div class="token-label">Input</div><div class="token-value input">${fmt(totalUsage.input)}</div></div>
@@ -821,15 +868,84 @@ function renderMessages(messages, ctx) {
821
868
  return true;
822
869
  });
823
870
  if (!filtered.length) return '<div class="empty"><p>Aucun message</p></div>';
824
- return filtered.map((m, i) => renderMessage(m, i, ctx)).join('');
871
+
872
+ // Track active skill/agent context across messages
873
+ let activeAgent = null;
874
+ return filtered.map((m, i) => {
875
+ if (m.type === 'assistant') {
876
+ const content = Array.isArray(m.content) ? m.content : [];
877
+ for (const block of content) {
878
+ if (!block || block.type !== 'tool_use') continue;
879
+ if (block.name === 'Skill') {
880
+ activeAgent = { kind: 'skill', name: block.input?.skill || '?' };
881
+ } else if (block.name === 'Agent') {
882
+ const name = block.input?.description || block.input?.subagent_type || 'agent';
883
+ activeAgent = { kind: 'agent', name: name.slice(0, 40) };
884
+ }
885
+ }
886
+ }
887
+ return renderMessage(m, i, ctx, activeAgent);
888
+ }).join('');
825
889
  }
826
890
 
827
- function renderMessage(m, i, ctx) {
891
+ function renderMessage(m, i, ctx, activeAgent) {
828
892
  const isUser = m.type === 'user';
829
893
  const isAgent = m.isSidechain;
830
894
  const collapseByDefault = !isUser && i > 0;
831
895
  const pfx = ctx === 'modal' ? 'modal-' : '';
832
896
 
897
+ // Extract tool info for assistant messages (used in header hint + usage chips)
898
+ const msgToolNames = [];
899
+ const mcpServers = [];
900
+ const skillsUsed = [];
901
+ if (!isUser) {
902
+ const contentArr = Array.isArray(m.content) ? m.content : [];
903
+ for (const block of contentArr) {
904
+ if (!block || block.type !== 'tool_use') continue;
905
+ msgToolNames.push(block.name);
906
+ if (block.name.startsWith('mcp__')) {
907
+ const parts = block.name.split('__');
908
+ const server = (parts[1] || '').replace(/^claude_ai_/, '').replace(/_/g, ' ');
909
+ if (server && !mcpServers.includes(server)) mcpServers.push(server);
910
+ } else if (block.name === 'Skill' && block.input?.skill) {
911
+ const sn = block.input.skill;
912
+ if (!skillsUsed.includes(sn)) skillsUsed.push(sn);
913
+ }
914
+ }
915
+ }
916
+ // Build a human-readable hint for the collapsed header
917
+ let toolHint = '';
918
+ if (!isUser && msgToolNames.length > 0) {
919
+ const hintParts = [];
920
+ // Agent spawns: show description
921
+ const contentArr2 = Array.isArray(m.content) ? m.content : [];
922
+ for (const block of contentArr2) {
923
+ if (!block || block.type !== 'tool_use') continue;
924
+ if (block.name === 'Agent') {
925
+ const desc = block.input?.description || block.input?.subagent_type || 'agent';
926
+ hintParts.push(`⬡ ${desc.slice(0, 40)}`);
927
+ } else if (block.name === 'Skill') {
928
+ hintParts.push(`skill:${block.input?.skill || '?'}`);
929
+ } else if (block.name.startsWith('mcp__')) {
930
+ const parts = block.name.split('__');
931
+ const server = (parts[1] || '').replace(/^claude_ai_/, '');
932
+ const tool = (parts[2] || '').replace(/^[a-z]+-/, '');
933
+ if (!hintParts.some(p => p.startsWith(`mcp:${server}`)))
934
+ hintParts.push(`mcp:${server}/${tool}`);
935
+ } else if (!['Read','Write','Edit','Bash','WebSearch','WebFetch','ToolSearch'].includes(block.name)) {
936
+ hintParts.push(block.name);
937
+ }
938
+ }
939
+ // Fallback to common tools if nothing notable
940
+ if (!hintParts.length) {
941
+ const common = msgToolNames.slice(0, 3);
942
+ hintParts.push(...common);
943
+ if (msgToolNames.length > 3) hintParts.push(`+${msgToolNames.length - 3}`);
944
+ }
945
+ toolHint = hintParts.slice(0, 3).join(' ');
946
+ if (hintParts.length > 3) toolHint += ` +${hintParts.length - 3}`;
947
+ }
948
+
833
949
  let bodyHtml = '';
834
950
  if (isUser) {
835
951
  if (typeof m.content === 'string') {
@@ -880,24 +996,38 @@ function renderMessage(m, i, ctx) {
880
996
  ${u.cache_creation_input_tokens ? `<span class="usage-chip">cache↑: <span class="cw">${fmt(u.cache_creation_input_tokens)}</span></span>` : ''}
881
997
  ${u.cache_read_input_tokens ? `<span class="usage-chip">cache↓: <span class="cr">${fmt(u.cache_read_input_tokens)}</span></span>` : ''}
882
998
  ${m.model ? `<span class="usage-chip" style="color:var(--accent)">${escHtml(modelShort(m.model))}</span>` : ''}
999
+ ${mcpServers.map(s => `<span class="usage-chip"><span class="tag-mcp">mcp</span>${escHtml(s)}</span>`).join('')}
1000
+ ${skillsUsed.map(s => `<span class="usage-chip"><span class="tag-skill">skill</span>${escHtml(s)}</span>`).join('')}
1001
+ ${activeAgent?.kind === 'skill' && !skillsUsed.includes(activeAgent.name) ? `<span class="usage-chip"><span class="tag-skill">skill</span>${escHtml(activeAgent.name)}</span>` : ''}
1002
+ ${(state.agentSkillsByUuid?.[m.uuid] || []).filter(s => !skillsUsed.includes(s)).map(s => `<span class="usage-chip"><span class="tag-skill">skill</span>${escHtml(s)}</span>`).join('')}
883
1003
  </div>`;
884
1004
  }
885
1005
 
886
1006
  const ts = m.timestamp ? fmtTime(m.timestamp) : '';
887
1007
  const msgId = `${pfx}msg-${i}`;
888
1008
 
1009
+ let agentBadge = '';
1010
+ if (!isUser && activeAgent) {
1011
+ const icon = activeAgent.kind === 'skill' ? '⚡' : '⬡';
1012
+ const col = activeAgent.kind === 'skill' ? 'var(--purple)' : 'var(--teal)';
1013
+ agentBadge = `<span class="msg-agent-badge" style="color:${col}">${icon} ${escHtml(activeAgent.name)}</span>`;
1014
+ }
1015
+
889
1016
  return `
890
1017
  <div class="message ${isAgent ? 'sidechain' : ''} ${collapseByDefault ? 'collapsed' : ''}" id="${msgId}" data-uuid="${escHtml(m.uuid || '')}">
891
1018
  <div class="message-header" onclick="this.parentElement.classList.toggle('collapsed')">
892
1019
  <span class="msg-role ${isUser ? 'user' : 'assistant'}">${isUser ? '👤 User' : '🤖 AI'}</span>
1020
+ ${agentBadge}
893
1021
  ${isAgent ? '<span class="badge badge-sidechain">agent</span>' : ''}
1022
+ ${toolHint ? `<span class="msg-tool-hint">${escHtml(toolHint)}</span>` : ''}
894
1023
  <div class="msg-meta">
895
1024
  ${ts ? `<span>${ts}</span>` : ''}
896
1025
  ${m.stopReason ? `<span style="color:var(--text3)">${escHtml(m.stopReason)}</span>` : ''}
897
1026
  </div>
898
1027
  <span class="msg-chevron">▼</span>
899
1028
  </div>
900
- <div class="message-body">${bodyHtml}${usageHtml}</div>
1029
+ <div class="message-body">${bodyHtml}</div>
1030
+ ${usageHtml ? `<div class="message-stats">${usageHtml}</div>` : ''}
901
1031
  </div>`;
902
1032
  }
903
1033
 
@@ -920,4 +1050,4 @@ window.setMsgFilter = setMsgFilter;
920
1050
  window.toggleTimeline = toggleTimeline;
921
1051
  window.closeAgentModal = closeAgentModal;
922
1052
 
923
- loadDashboard();
1053
+ restoreFromUrl();
@@ -3,8 +3,8 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>ccanalyser</title>
7
- <link rel="stylesheet" href="style.css" />
6
+ <title>ccanalyzer</title>
7
+ <link rel="stylesheet" href="style.css?v=9" />
8
8
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
9
9
  </head>
10
10
  <body>
@@ -12,7 +12,7 @@
12
12
  <nav id="sidebar">
13
13
  <div class="logo">
14
14
  <span class="logo-icon">◈</span>
15
- <span class="logo-text">ccanalyser</span>
15
+ <span class="logo-text">ccanalyzer</span>
16
16
  </div>
17
17
  <div id="nav-menu">
18
18
  <a href="#" class="nav-item active" data-view="dashboard">
@@ -33,6 +33,6 @@
33
33
  <div class="spinner"></div>
34
34
  </div>
35
35
 
36
- <script src="app.js"></script>
36
+ <script src="app.js?v=9"></script>
37
37
  </body>
38
38
  </html>
@@ -241,7 +241,7 @@ tr:hover td { background: var(--bg3); cursor: pointer; }
241
241
  display: flex;
242
242
  flex-wrap: wrap;
243
243
  gap: 12px;
244
- margin-bottom: 24px;
244
+ margin-bottom: 14px;
245
245
  padding: 16px 20px;
246
246
  background: var(--bg2);
247
247
  border: 1px solid var(--border);
@@ -262,7 +262,7 @@ tr:hover td { background: var(--bg3); cursor: pointer; }
262
262
  background: var(--bg2);
263
263
  border: 1px solid var(--border);
264
264
  border-radius: var(--radius2);
265
- margin-bottom: 24px;
265
+ margin-bottom: 14px;
266
266
  flex-wrap: wrap;
267
267
  }
268
268
  .token-item { display: flex; flex-direction: column; gap: 3px; }
@@ -335,6 +335,31 @@ tr:hover td { background: var(--bg3); cursor: pointer; }
335
335
  }
336
336
  .message.collapsed .message-body { display: none; }
337
337
 
338
+ .message-stats {
339
+ padding: 8px 16px 10px;
340
+ background: var(--bg2);
341
+ border-top: 1px solid var(--border);
342
+ }
343
+ .message-stats .usage-inline {
344
+ margin-top: 0;
345
+ padding-top: 0;
346
+ border-top: none;
347
+ }
348
+
349
+ .msg-agent-badge {
350
+ font-family: var(--font);
351
+ font-size: 10px;
352
+ font-weight: 600;
353
+ padding: 1px 6px;
354
+ border-radius: 4px;
355
+ background: #1a1e28;
356
+ flex-shrink: 0;
357
+ white-space: nowrap;
358
+ overflow: hidden;
359
+ text-overflow: ellipsis;
360
+ max-width: 200px;
361
+ }
362
+
338
363
  .msg-text {
339
364
  color: var(--text);
340
365
  line-height: 1.65;
@@ -405,6 +430,41 @@ tr:hover td { background: var(--bg3); cursor: pointer; }
405
430
  .usage-chip .cw { color: var(--yellow); }
406
431
  .usage-chip .cr { color: var(--teal); }
407
432
 
433
+ .tag-mcp, .tag-skill {
434
+ display: inline-block;
435
+ font-size: 9px;
436
+ font-weight: 700;
437
+ text-transform: uppercase;
438
+ letter-spacing: 0.5px;
439
+ padding: 1px 4px;
440
+ border-radius: 3px;
441
+ margin-right: 4px;
442
+ }
443
+ .tag-mcp { background: #1a2e2a; color: var(--teal); }
444
+ .tag-skill { background: #221a30; color: var(--purple); }
445
+
446
+ .msg-tool-hint {
447
+ font-family: var(--font);
448
+ font-size: 11px;
449
+ color: var(--text3);
450
+ overflow: hidden;
451
+ text-overflow: ellipsis;
452
+ white-space: nowrap;
453
+ max-width: 320px;
454
+ flex-shrink: 1;
455
+ }
456
+
457
+ .session-tools-bar {
458
+ display: flex;
459
+ flex-wrap: wrap;
460
+ gap: 8px;
461
+ padding: 10px 16px;
462
+ background: var(--bg2);
463
+ border: 1px solid var(--border);
464
+ border-radius: var(--radius);
465
+ margin-bottom: 14px;
466
+ }
467
+
408
468
  /* ── Back button ── */
409
469
  .back-btn {
410
470
  display: inline-flex;